ldap-rest 1.3.0 → 2.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 (82) hide show
  1. package/README.md +8 -1
  2. package/dist/abstract/ldapFlat.js +1 -1
  3. package/dist/abstract/plugin.js +1 -1
  4. package/dist/bin/index.js +1 -1
  5. package/dist/config/options.js +1 -1
  6. package/dist/config/schema.js +1 -1
  7. package/dist/lib/authz/base.js +1 -1
  8. package/dist/lib/errors.js +1 -1
  9. package/dist/lib/ldap/callerView.js +1 -0
  10. package/dist/lib/ldap/pool.js +1 -0
  11. package/dist/lib/ldap/schema.js +1 -0
  12. package/dist/lib/ldap/searchCache.js +1 -0
  13. package/dist/lib/ldapActions.js +1 -1
  14. package/dist/lib/parseConfig.js +1 -1
  15. package/dist/lib/services/index.js +1 -0
  16. package/dist/lib/utils.js +1 -1
  17. package/dist/plugins/ldap/accountLifecycle.js +1 -1
  18. package/dist/plugins/ldap/enterpriseRules.js +1 -1
  19. package/dist/plugins/ldap/groups.js +1 -1
  20. package/dist/plugins/ldap/organizations.js +1 -1
  21. package/dist/plugins/ldap/passwordPolicy.js +1 -1
  22. package/dist/plugins/ldap/raw.js +1 -1
  23. package/dist/plugins/ldap/trash.js +1 -1
  24. package/dist/plugins/scim/mapping.js +1 -1
  25. package/dist/plugins/scim/patch.js +1 -1
  26. package/dist/plugins/storage/file.js +1 -1
  27. package/dist/plugins/twake/instances.js +1 -1
  28. package/dist/plugins/twake/lifecycleAttributes.js +1 -1
  29. package/dist/plugins/twake/lifecycleEvents.js +1 -1
  30. package/dist/plugins/twake/tombstone.js +1 -1
  31. package/dist/src/abstract/ldapFlat.d.ts +13 -0
  32. package/dist/src/abstract/plugin.d.ts +21 -0
  33. package/dist/src/bin/index.d.ts +38 -4
  34. package/dist/src/config/options.d.ts +16 -1
  35. package/dist/src/lib/errors.d.ts +2 -1
  36. package/dist/src/lib/ldap/callerView.d.ts +45 -0
  37. package/dist/src/lib/ldap/pool.d.ts +108 -0
  38. package/dist/src/lib/ldap/schema.d.ts +157 -0
  39. package/dist/src/lib/ldap/searchCache.d.ts +112 -0
  40. package/dist/src/lib/ldapActions.d.ts +25 -262
  41. package/dist/src/lib/parseConfig.d.ts +26 -2
  42. package/dist/src/lib/services/index.d.ts +56 -0
  43. package/dist/src/lib/utils.d.ts +72 -3
  44. package/dist/src/plugins/ldap/enterpriseRules.d.ts +0 -14
  45. package/dist/src/plugins/ldap/passwordPolicy.d.ts +3 -2
  46. package/dist/src/plugins/scim/mapping.d.ts +16 -3
  47. package/dist/src/plugins/scim/types.d.ts +2 -0
  48. package/dist/src/plugins/twake/instances.d.ts +31 -0
  49. package/dist/src/plugins/twake/lifecycleAttributes.d.ts +13 -2
  50. package/dist/src/plugins/twake/tombstone.d.ts +9 -2
  51. package/docs/README.md +25 -2
  52. package/docs/architecture/README.md +180 -0
  53. package/docs/architecture/adr/0001-plugin-load-order.md +50 -0
  54. package/docs/architecture/adr/0002-authentication-dispatch.md +43 -0
  55. package/docs/architecture/adr/0003-authorization-composition.md +59 -0
  56. package/docs/architecture/adr/0004-fail-closed-request-context.md +45 -0
  57. package/docs/architecture/adr/0005-hook-contract.md +54 -0
  58. package/docs/architecture/adr/0006-search-cache.md +49 -0
  59. package/docs/architecture/adr/0007-trash-and-tombstone.md +69 -0
  60. package/docs/architecture/adr/0008-withconfig-views.md +58 -0
  61. package/docs/client-development/api/rest-api.md +4 -0
  62. package/docs/development/contracts.md +119 -0
  63. package/docs/development/modularization-plan.md +197 -0
  64. package/docs/plugin-development/README.md +8 -1
  65. package/docs/plugin-development/dependencies.md +13 -2
  66. package/docs/plugin-development/hooks.md +4 -2
  67. package/docs/plugin-development/options.md +40 -13
  68. package/docs/plugin-development/services.md +95 -0
  69. package/docs/usage/operations.md +12 -0
  70. package/docs/usage/plugins/integrations/instances.md +11 -3
  71. package/docs/usage/plugins/integrations/lifecycle-events.md +4 -1
  72. package/docs/usage/plugins/integrations/scim.md +20 -18
  73. package/docs/usage/plugins/integrations/tombstone.md +10 -6
  74. package/docs/usage/plugins/ldap/flat-generic.md +8 -1
  75. package/docs/usage/plugins/ldap/groups.md +4 -1
  76. package/docs/usage/plugins/ldap/password-policy.md +7 -0
  77. package/docs/usage/upgrading.md +59 -0
  78. package/package.json +21 -12
  79. package/static/browser/ldap-tree-viewer/LdapTreeViewer.js.map +1 -1
  80. package/static/browser/ldap-tree-viewer.esm.js.map +1 -1
  81. package/static/browser/ldap-tree-viewer.js.map +1 -1
  82. package/static/browser/ldap-tree-viewer.min.js.map +1 -1
@@ -1 +1 @@
1
- import{Type as t}from"@sinclair/typebox";import e from"../../abstract/plugin.js";import{NotFoundError as i,BadRequestError as a}from"../../lib/errors.js";import{CozyStackProvider as n,ClouderyProvider as s,matrixId as r,instanceSlug as o}from"../../lib/instanceProviders.js";import{extractLdapCode as c}from"../../lib/ldapCodes.js";import{rdnValue as h,escapeLdapFilter as d}from"../../lib/utils.js";import{lifecycleAttributes as l,isTombstone as u}from"./lifecycleAttributes.js";import{twakeInstancesOptions as g}from"./twakeInstances.options.js";import{defineRoute as w}from"../../lib/route.js";const first=t=>{const e=Array.isArray(t)?t[0]:t;return(null==e?"":e.toString())||void 0},f=/(^|\/)twake\/(cozy|cloudery)Provision(\.js)?$/,m=t.Object({dn:t.Optional(t.String()),mail:t.Optional(t.String())}),p=t.Object({state:t.Union([t.Literal("ready"),t.Literal("pending")])});class TwakeInstances extends e{static optionDefinitions=g;name="twakeInstances";roles=["consistency","api"];dependencies={rabbitmq:"core/rabbitmq"};settings;patterns;provider;fqdnAttribute;sentAttribute;locale;lifecycle;offers=[];queues=new Map;announcing=new Map;constructor(t){super(t);const e=this.options(g);this.settings=e;const i=(t.config.plugin||[]).find(t=>f.test(t));if(i)throw new Error(`${this.name}: ${i} gives accounts their instance too; load one of them`);this.patterns=e.twake_instance_dn.map(t=>new RegExp(t,"i"));const a=new Set(["uid"]);for(const t of this.patterns)for(const[,e]of t.source.matchAll(/\(\?<(\w+)>/g))a.add(e);for(const[,t]of e.twake_instance_id.matchAll(/\{(\w+)\}/g))if(!a.has(t))throw new Error(`${this.name}: --twake-instance-id names {${t}}, which no --twake-instance-dn group captures`);this.lifecycle=l(t.config),this.fqdnAttribute=e.twake_instance_fqdn_attribute,this.sentAttribute=e.twake_instance_sent_attribute;const r=e.twake_instance_timeout;if("cozy-stack"===e.twake_instance_provider){if(!e.twake_instance_cozy_url||!e.twake_instance_cozy_domain)throw new Error(`${this.name}: --twake-instance-cozy-url and --twake-instance-cozy-domain are required`);this.provider=new n(e.twake_instance_cozy_url.replace(/\/$/,""),e.twake_instance_cozy_user,e.twake_instance_cozy_passphrase,e.twake_instance_cozy_domain,e.twake_instance_cozy_context,e.twake_instance_cozy_apps,r),this.locale=e.twake_instance_locale||"fr"}else{if("cloudery"!==e.twake_instance_provider)throw new Error(`${this.name}: --twake-instance-provider must be cloudery or cozy-stack`);{if(!e.twake_instance_cloudery_url||!e.twake_instance_cloudery_domain)throw new Error(`${this.name}: --twake-instance-cloudery-url and --twake-instance-cloudery-domain are required`);const t=e.twake_instance_cloudery_offer.split(";").map(t=>t.trim());if(t.length>1&&""===t[t.length-1]&&t.pop(),t.length>1&&t.length>this.patterns.length)throw new Error(`${this.name}: --twake-instance-cloudery-offer has more offers than --twake-instance-dn has expressions`);t.length>1&&(this.offers=t),this.provider=new s(e.twake_instance_cloudery_url.replace(/\/$/,""),e.twake_instance_cloudery_token,e.twake_instance_cloudery_domain,t.length>1?"":t[0],r),this.locale=e.twake_instance_locale||"en"}}}hooks={ldapaddrequest:([t,e,i])=>{if(this.patterns.some(e=>e.test(t))||this.isOrganization(t)){const t=[this.fqdnAttribute,this.sentAttribute,this.organizationFqdn].map(t=>t.toLowerCase());for(const i of Object.keys(e))t.includes(i.toLowerCase())&&delete e[i]}return[t,e,i]},ldapaddafter:async(t,e)=>{const i=this.account(t,e);i&&(await this.serialized(t,async()=>{try{await this.provide(t,e,i)}catch(e){this.logger.error({plugin:this.name,event:"create",dn:t,error:String(e)})}}),i.orgId&&await this.announcing.get(i.orgId.toLowerCase()))},ldapdeletedone:async t=>{const e=this.provider;if(e instanceof n)for(const i of Array.isArray(t)?t:[t]){const t=this.match(i),a=/^uid=/i.test(i)?h(i):"";t&&a&&await this.serialized(i,async()=>{try{await e.destroy(this.id(a,t),a)}catch(t){this.logger.error({plugin:this.name,event:"destroy",dn:i,error:String(t)})}})}}};async api(t){const e=this.requirePlugin("rabbitmq");if(!e||!await e.getRawClient())throw new Error(`${this.name}: RabbitMQ is required and unreachable`);const n=await this.server.ldap.schemaIndex(),r=[this.fqdnAttribute,this.sentAttribute,...this.settings.twake_instance_organization_base?[this.organizationDomain,this.organizationFqdn,this.organizationName]:[]].filter(t=>t&&!n.getAttributeType(t));if(r.length)throw new Error(`${this.name}: the directory schema defines no ${r.join(", ")}; load a schema that does, or name other attributes`);if(this.organizationFqdn&&this.organizationFqdn.toLowerCase()===this.organizationName.toLowerCase())throw new Error(`${this.name}: an organization's name and address cannot share the attribute ${this.organizationName}`);w(t,{method:"post",path:`${this.config.api_prefix}/v1/twake/instances/ensure`,body:m,responses:{200:p},handler:async({req:t,res:e,body:n})=>{let s=n.dn;if(void 0===s&&n.mail){const e=await this.findByMail(n.mail,t);if(!e)throw new i(`No account for ${n.mail}`);s=e.dn}if(!s)throw new a("Give the account as dn or mail");e.json({state:await this.ensureInstance(s,t)})}}),this.provider instanceof s&&await e.subscribe(this.settings.twake_instance_auth_exchange,this.settings.twake_instance_workplace_created_key,this.settings.twake_instance_queue,t=>this.onWorkplaceCreated(t))}async ensureInstance(t,e){let n;const s=await this.serialized(t,async()=>{const s=await this.read(t,e);if(!s)throw new i(`No entry at ${t}`);const r=this.account(t,s);if(!r)throw new a(`${t} gets no instance`);if(n=r.orgId,first(this.value(s,this.sentAttribute)))return"ready";e&&await this.server.ldap.modify(t,{replace:{[this.sentAttribute]:[]}},e);const o=first(this.value(s,this.fqdnAttribute));if(o){const i=o.toLowerCase()===this.provider.address(r)?{fqdn:o,ready:!0}:await this.provider.find(r,o);if(i?.ready)return await this.ready(t,s,r,i.fqdn,e),"ready"}return this.provide(t,s,r,e)});return n&&await this.announcing.get(n.toLowerCase()),s}async ensureOrganization(t){return this.serialized(`organization:${t.id}`,async()=>{const e=await this.findOrganization(t.id);if(!e)throw new i(`No organization ${t.id}`);t.name&&!first(this.value(e,this.organizationName))&&(await this.server.ldap.system.modify(e.dn,{replace:{[this.organizationName]:t.name}}),e[this.organizationName]=t.name);const a=this.organizationFqdn&&first(this.value(e,this.organizationFqdn)),n=this.organizationRequest(t),r=a?{fqdn:a,ready:!0}:await this.provider.find(n);if(first(this.value(e,this.sentAttribute)))return r?.ready?await this.organizationReady(e,r.fqdn):await this.releaseMembers(e.dn),"ready";let o=r?.ready&&r.fqdn;return r||(this.provider instanceof s&&await this.provider.createOrganization(t),o=await this.provider.create(n)),o?(await this.organizationReady(e,o),"ready"):"pending"})}organizationRequest(t){return{id:t.id,email:this.organizationMail(t.id,t.domain),publicName:t.name,locale:this.locale,orgId:t.id,orgDomain:t.domain,offer:this.settings.twake_instance_cloudery_organization_offer,organization:!0}}async requestOrganization(t,e){if(!(this.provider instanceof s))return;const i=await this.findOrganization(t);if(!i||first(this.value(i,this.sentAttribute)))return;const a=first(this.value(i,this.organizationName)),n=first(this.value(i,this.organizationDomain));if(!a||!n)return void this.logger.warn(`${this.name}: organization ${t} has no ${this.organizationName} or ${this.organizationDomain}, so its instance cannot be asked for`);const r=this.organizationRequest({id:t,name:a,domain:n}),o=await this.provider.find(r);if(o){const s=t.toLowerCase();if(!o.ready||this.announcing.has(s))return;return e&&await this.server.ldap.modify(i.dn,{},e),void(this.announcing.has(s)||this.announcing.set(s,this.ensureOrganization({id:t,name:a,domain:n}).catch(e=>{this.logger.error({plugin:this.name,event:"organization",id:t,error:String(e)})}).finally(()=>this.announcing.delete(s))))}await this.provider.createOrganization({id:t,name:a,domain:n});try{await this.provider.create(r)}catch(t){if(!await this.provider.find(r))throw t}}async onWorkplaceCreated(t){const{internalEmail:e,workplaceFqdn:i}=t;if(!e||!i)return;const a=t.twakeId&&await this.findOrganization(t.twakeId);if(a){const n=t.twakeId.toLowerCase(),s=first(this.value(a,this.organizationDomain)),r=e.toLowerCase();if(s?this.organizationMail(n,s)===r:r.startsWith(`${n}@`))return this.serialized(`organization:${t.twakeId}`,async()=>{const t=await this.read(a.dn);t&&await this.organizationReady(t,i)})}const n=await this.findByMail(e);n?await this.settle(n.dn,i):this.logger.debug(`${this.name}: no account here for ${e}`)}async settle(t,e){await this.serialized(t,async()=>{const i=await this.read(t),a=i&&this.account(t,i);i&&a&&(first(this.value(i,this.sentAttribute))||await this.ready(t,i,a,e))})}async provide(t,e,i,a){i.orgId&&await this.requestOrganization(i.orgId,a);const n=await this.provider.find(i),s=n?n.ready&&n.fqdn:await this.provider.create(i);return s?(await this.ready(t,e,i,s,a),"ready"):"pending"}async ready(t,e,i,a,n){const o={declaredClasses:this.config.user_class};if(first(this.value(e,this.fqdnAttribute))!==a&&await this.server.ldap.modify(t,{replace:{[this.fqdnAttribute]:a}},n,o),first(this.value(e,this.sentAttribute)))return;const c=this.provider instanceof s&&i.orgId&&await this.findOrganization(i.orgId);if(c&&!first(this.value(c,this.sentAttribute))){if(t.toLowerCase().endsWith(`,${c.dn.toLowerCase()}`))return void this.logger.info(`${this.name}: ${t} waits for organization ${i.orgId}`);this.logger.warn(`${this.name}: ${t} is not below its organization entry, so it is announced before organization ${i.orgId} is linked; clear its ${this.sentAttribute} and send it again once the organization is announced`)}const h=r(i.email);await this.announce(this.settings.twake_instance_auth_exchange,this.settings.twake_instance_user_created_key,{twakeId:i.twakeId,internalEmail:i.email,workplaceFqdn:a,...h?{matrixId:h}:{},...i.orgId?{organizationId:i.orgId}:{},...i.orgDomain?{domain:i.orgDomain,organizationDomain:i.orgDomain}:{},...i.phone?{mobile:i.phone}:{}}),await this.server.ldap.modify(t,{replace:{[this.sentAttribute]:(new Date).toISOString()}},n,o)}async organizationReady(t,e){const i=t.dn,a=first(this.value(t,this.organizationId));if(a&&this.provider instanceof s&&await this.provider.linkOrganization(a,e),first(this.value(t,this.sentAttribute)))return this.releaseMembers(i);this.organizationFqdn&&!first(this.value(t,this.organizationFqdn))&&await this.server.ldap.system.modify(i,{replace:{[this.organizationFqdn]:e}});const n=first(this.value(t,this.organizationDomain)),r=first(this.value(t,this.organizationName));await this.announce(this.settings.twake_instance_b2b_exchange,this.settings.twake_instance_organization_created_key,{organizationId:a,workplaceFqdn:e,...r?{organization:r}:{},...n?{domain:n}:{}}),await this.server.ldap.system.modify(i,{replace:{[this.sentAttribute]:(new Date).toISOString()}}),await this.releaseMembers(i)}async releaseMembers(t){const e=await this.server.ldap.system.search({paged:!1,scope:"sub",filter:`(&(${this.fqdnAttribute}=*)(!(${this.sentAttribute}=*)))`},t);let i=0;for(const t of e.searchEntries)try{await this.release(t)}catch(e){i++,this.logger.error(`${this.name}: ${t.dn} not released: ${String(e)}`)}if(i)throw new Error(`${this.name}: ${i} member(s) of ${t} not released`)}async release(t){const e=t.dn,i=this.account(e,t),a=first(this.value(t,this.fqdnAttribute));if(!i||!a)return;const n=a.toLowerCase()===this.provider.address(i)?{fqdn:a,ready:!0}:await this.provider.find(i,a);if(n?.ready)return this.settle(e,n.fqdn);this.logger.warn(`${this.name}: ${e} stays held, its address ${a} is not confirmed; ensureInstance sends it once it is`)}async serialized(t,e){const i=t.toLowerCase(),a=(this.queues.get(i)??Promise.resolve()).catch(()=>{}).then(e),n=a.catch(()=>{});return this.queues.set(i,n),n.then(()=>{this.queues.get(i)===n&&this.queues.delete(i)}),a}get organizationId(){return this.settings.twake_instance_organization_id_attribute}get organizationDomain(){return this.settings.twake_instance_organization_domain_attribute}get organizationName(){return this.settings.twake_instance_organization_name_attribute}get organizationFqdn(){return this.settings.twake_instance_organization_fqdn_attribute}organizationMail(t,e){return`${t}@${e}`.toLowerCase()}isOrganization(t){const e=this.settings.twake_instance_organization_base;if(!e)return!1;return t.slice(t.indexOf(",")+1).toLowerCase()===e.toLowerCase()}async findOrganization(t){const e=this.settings.twake_instance_organization_base;if(!e)return;return(await this.server.ldap.system.search({paged:!1,scope:"one",filter:`(${this.organizationId}=${d(t)})`},e)).searchEntries[0]}async read(t,e){try{return(await this.server.ldap.search({paged:!1,scope:"base"},t,e)).searchEntries[0]}catch(e){const i=c(e);if(32===i)return;if(34===i)throw new a(`Invalid DN: ${t}`);throw e}}async announce(t,e,i){const a=this.requirePlugin("rabbitmq");if(!a||!await a.getRawClient())throw new Error(`${this.name}: RabbitMQ unreachable, ${e} not sent`);await a.publish(t,e,i),this.logger.info({plugin:this.name,event:e,exchange:t})}account(t,e){const i=this.match(t);if(!i)return;if(u(e,this.lifecycle))return;const a=this.settings.twake_instance_skip_attribute;if(a&&first(this.value(e,a))?.toLowerCase()===this.settings.twake_instance_skip_value.toLowerCase())return;const s=first(this.value(e,"mail")),r=first(this.value(e,"uid"));if(!s||!r)return void this.logger.warn(`${this.name}: ${t} has no mail or uid, no instance`);const o=this.provider instanceof n,c=i.groups?.org||(o?this.settings.twake_instance_cozy_org_id:"")||void 0,h=i.groups?.org?s.split("@")[1]:o&&this.settings.twake_instance_cozy_org_domain||void 0,d=this.offers[this.patterns.findIndex(e=>e.test(t))],l=this.id(r,i);if(l)return{id:l,...o?{oidc:r}:{},twakeId:r,email:s,publicName:first(this.value(e,"displayName"))||first(this.value(e,"cn"))||r,locale:first(this.value(e,"preferredLanguage"))||this.locale,phone:first(this.value(e,"mobile")),...c?{orgId:c}:{},...h?{orgDomain:h}:{},...d?{offer:d}:{}};this.logger.warn(`${this.name}: ${t} gives no instance slug, no instance`)}match(t){return this.patterns.map(e=>e.exec(t)).find(t=>null!==t)}id(t,e){const i={uid:t,...e.groups};return o(this.settings.twake_instance_id.replace(/\{(\w+)\}/g,(t,e)=>i[e]??""))}value(t,e){const i=e.toLowerCase(),a=Object.keys(t).find(t=>t.toLowerCase()===i);return a?t[a]:void 0}async findByMail(t,e){return(await this.server.ldap.search({paged:!1,scope:"sub",filter:`(mail=${d(t)})`},this.config.ldap_base,e)).searchEntries.find(t=>this.patterns.some(e=>e.test(t.dn)))}}export{m as EnsureInstanceBody,p as EnsureInstanceResponse,TwakeInstances as default};
1
+ import{Type as t}from"@sinclair/typebox";import e from"../../abstract/plugin.js";import{NotFoundError as i,BadRequestError as a}from"../../lib/errors.js";import{CozyStackProvider as n,ClouderyProvider as s,matrixId as r,instanceSlug as o}from"../../lib/instanceProviders.js";import{extractLdapCode as c}from"../../lib/ldapCodes.js";import{isChildOf as h,isDirectChildOf as d,rdnValue as l,escapeLdapFilter as u,unescapeDnValue as g,canonicalDn as w,rdnTypes as f,normalizeDn as m}from"../../lib/utils.js";import{lifecycleAttributes as p,isTombstone as _}from"./lifecycleAttributes.js";import{twakeInstancesOptions as y}from"./twakeInstances.options.js";import{defineRoute as z}from"../../lib/route.js";const first=t=>{const e=Array.isArray(t)?t[0]:t;return(null==e?"":e.toString())||void 0},v=/(^|\/)twake\/(cozy|cloudery)Provision(\.js)?$/,k=t.Object({dn:t.Optional(t.String()),mail:t.Optional(t.String())}),b=t.Object({state:t.Union([t.Literal("ready"),t.Literal("pending")])});class TwakeInstances extends e{static optionDefinitions=y;name="twakeInstances";roles=["consistency","api"];dependencies={rabbitmq:"core/rabbitmq"};settings;patterns;provider;fqdnAttribute;sentAttribute;locale;lifecycle;offers=[];queues=new Map;announcing=new Map;constructor(t){super(t);const e=this.options(y);this.settings=e;const i=(t.config.plugin||[]).find(t=>v.test(t));if(i)throw new Error(`${this.name}: ${i} gives accounts their instance too; load one of them`);this.patterns=e.twake_instance_dn.map(t=>new RegExp(t,"i"));const a=new Set(["uid"]);for(const t of this.patterns)for(const[,e]of t.source.matchAll(/\(\?<(\w+)>/g))a.add(e);for(const[,t]of e.twake_instance_id.matchAll(/\{(\w+)\}/g))if(!a.has(t))throw new Error(`${this.name}: --twake-instance-id names {${t}}, which no --twake-instance-dn group captures`);this.lifecycle=p(t.config),this.fqdnAttribute=e.twake_instance_fqdn_attribute,this.sentAttribute=e.twake_instance_sent_attribute;const r=e.twake_instance_timeout;if("cozy-stack"===e.twake_instance_provider){if(!e.twake_instance_cozy_url||!e.twake_instance_cozy_domain)throw new Error(`${this.name}: --twake-instance-cozy-url and --twake-instance-cozy-domain are required`);this.provider=new n(e.twake_instance_cozy_url.replace(/\/$/,""),e.twake_instance_cozy_user,e.twake_instance_cozy_passphrase,e.twake_instance_cozy_domain,e.twake_instance_cozy_context,e.twake_instance_cozy_apps,r),this.locale=e.twake_instance_locale||"fr"}else{if("cloudery"!==e.twake_instance_provider)throw new Error(`${this.name}: --twake-instance-provider must be cloudery or cozy-stack`);{if(!e.twake_instance_cloudery_url||!e.twake_instance_cloudery_domain)throw new Error(`${this.name}: --twake-instance-cloudery-url and --twake-instance-cloudery-domain are required`);const t=e.twake_instance_cloudery_offer.split(";").map(t=>t.trim());if(t.length>1&&""===t[t.length-1]&&t.pop(),t.length>1&&t.length>this.patterns.length)throw new Error(`${this.name}: --twake-instance-cloudery-offer has more offers than --twake-instance-dn has expressions`);t.length>1&&(this.offers=t),this.provider=new s(e.twake_instance_cloudery_url.replace(/\/$/,""),e.twake_instance_cloudery_token,e.twake_instance_cloudery_domain,t.length>1?"":t[0],r),this.locale=e.twake_instance_locale||"en"}}}hooks={ldapaddrequest:([t,e,i])=>{if(this.match(t)||this.isOrganization(t)){const t=[this.fqdnAttribute,this.sentAttribute,this.organizationFqdn].map(t=>t.toLowerCase());for(const i of Object.keys(e))t.includes(i.toLowerCase())&&delete e[i]}return[t,e,i]},ldapaddafter:async(t,e)=>{const i=await this.storedAccount(t,e);i&&(await this.serialized(this.key(t),async()=>{try{await this.provide(t,e,i)}catch(e){this.logger.error({plugin:this.name,event:"create",dn:t,error:String(e)})}}),i.orgId&&await this.announcing.get(i.orgId.toLowerCase()))},ldapdeletedone:async t=>{const e=this.provider;if(e instanceof n)for(const i of Array.isArray(t)?t:[t]){const t=this.match(i),a=this.uid(i);t&&a&&await this.serialized(this.key(i),async()=>{try{await e.destroy(this.id(a,t.exec),a)}catch(t){this.logger.error({plugin:this.name,event:"destroy",dn:i,error:String(t)})}})}}};async api(t){const e=this.requirePlugin("rabbitmq");if(!e||!await e.getRawClient())throw new Error(`${this.name}: RabbitMQ is required and unreachable`);const n=await this.server.ldap.schemaIndex(),r=[this.fqdnAttribute,this.sentAttribute,...this.settings.twake_instance_organization_base?[this.organizationDomain,this.organizationFqdn,this.organizationName]:[]].filter(t=>t&&!n.getAttributeType(t));if(r.length)throw new Error(`${this.name}: the directory schema defines no ${r.join(", ")}; load a schema that does, or name other attributes`);if(this.organizationFqdn&&this.organizationFqdn.toLowerCase()===this.organizationName.toLowerCase())throw new Error(`${this.name}: an organization's name and address cannot share the attribute ${this.organizationName}`);z(t,{method:"post",path:`${this.config.api_prefix}/v1/twake/instances/ensure`,body:k,responses:{200:b},handler:async({req:t,res:e,body:n})=>{let s=n.dn;if(void 0===s&&n.mail){const e=await this.findByMail(n.mail,t);if(!e)throw new i(`No account for ${n.mail}`);s=e.dn}if(!s)throw new a("Give the account as dn or mail");e.json({state:await this.ensureInstance(s,t)})}}),this.provider instanceof s&&await e.subscribe(this.settings.twake_instance_auth_exchange,this.settings.twake_instance_workplace_created_key,this.settings.twake_instance_queue,t=>this.onWorkplaceCreated(t))}async ensureInstance(t,e){let n;const s=await this.serialized(this.key(t),async()=>{const s=await this.read(t,e);if(!s)throw new i(`No entry at ${t}`);const r=await this.storedAccount(t,s);if(!r)throw new a(`${t} gets no instance`);if(n=r.orgId,first(this.value(s,this.sentAttribute)))return"ready";e&&await this.server.ldap.modify(t,{replace:{[this.sentAttribute]:[]}},e);const o=first(this.value(s,this.fqdnAttribute));if(o){const i=o.toLowerCase()===this.provider.address(r)?{fqdn:o,ready:!0}:await this.provider.find(r,o);if(i?.ready)return await this.ready(t,s,r,i.fqdn,e),"ready"}return this.provide(t,s,r,e)});return n&&await this.announcing.get(n.toLowerCase()),s}async ensureOrganization(t){return this.serialized(`organization:${t.id}`,async()=>{const e=await this.findOrganization(t.id);if(!e)throw new i(`No organization ${t.id}`);t.name&&!first(this.value(e,this.organizationName))&&(await this.server.ldap.system.modify(e.dn,{replace:{[this.organizationName]:t.name}}),e[this.organizationName]=t.name);const a=this.organizationFqdn&&first(this.value(e,this.organizationFqdn)),n=this.organizationRequest(t),r=a?{fqdn:a,ready:!0}:await this.provider.find(n);if(first(this.value(e,this.sentAttribute)))return r?.ready?await this.organizationReady(e,r.fqdn):await this.releaseMembers(e.dn),"ready";let o=r?.ready&&r.fqdn;return r||(this.provider instanceof s&&await this.provider.createOrganization(t),o=await this.provider.create(n)),o?(await this.organizationReady(e,o),"ready"):"pending"})}organizationRequest(t){return{id:t.id,email:this.organizationMail(t.id,t.domain),publicName:t.name,locale:this.locale,orgId:t.id,orgDomain:t.domain,offer:this.settings.twake_instance_cloudery_organization_offer,organization:!0}}async requestOrganization(t,e){if(!(this.provider instanceof s))return;const i=await this.findOrganization(t);if(!i||first(this.value(i,this.sentAttribute)))return;const a=first(this.value(i,this.organizationName)),n=first(this.value(i,this.organizationDomain));if(!a||!n)return void this.logger.warn(`${this.name}: organization ${t} has no ${this.organizationName} or ${this.organizationDomain}, so its instance cannot be asked for`);const r=this.organizationRequest({id:t,name:a,domain:n}),o=await this.provider.find(r);if(o){const s=t.toLowerCase();if(!o.ready||this.announcing.has(s))return;return e&&await this.server.ldap.modify(i.dn,{},e),void(this.announcing.has(s)||this.announcing.set(s,this.ensureOrganization({id:t,name:a,domain:n}).catch(e=>{this.logger.error({plugin:this.name,event:"organization",id:t,error:String(e)})}).finally(()=>this.announcing.delete(s))))}await this.provider.createOrganization({id:t,name:a,domain:n});try{await this.provider.create(r)}catch(t){if(!await this.provider.find(r))throw t}}async onWorkplaceCreated(t){const{internalEmail:e,workplaceFqdn:i}=t;if(!e||!i)return;const a=t.twakeId&&await this.findOrganization(t.twakeId);if(a){const n=t.twakeId.toLowerCase(),s=first(this.value(a,this.organizationDomain)),r=e.toLowerCase();if(s?this.organizationMail(n,s)===r:r.startsWith(`${n}@`))return this.serialized(`organization:${t.twakeId}`,async()=>{const t=await this.read(a.dn);t&&await this.organizationReady(t,i)})}const n=await this.findByMail(e);n?await this.settle(n.dn,i):this.logger.debug(`${this.name}: no account here for ${e}`)}async settle(t,e){await this.serialized(this.key(t),async()=>{const i=await this.read(t),a=i&&this.account(t,i);i&&a&&(first(this.value(i,this.sentAttribute))||await this.ready(t,i,a,e))})}async provide(t,e,i,a){i.orgId&&await this.requestOrganization(i.orgId,a);const n=await this.provider.find(i),s=n?n.ready&&n.fqdn:await this.provider.create(i);return s?(await this.ready(t,e,i,s,a),"ready"):"pending"}async ready(t,e,i,a,n){const o={declaredClasses:this.config.user_class};if(first(this.value(e,this.fqdnAttribute))!==a&&await this.server.ldap.modify(t,{replace:{[this.fqdnAttribute]:a}},n,o),first(this.value(e,this.sentAttribute)))return;const c=this.provider instanceof s&&i.orgId&&await this.findOrganization(i.orgId);if(c&&!first(this.value(c,this.sentAttribute))){if(h(t,c.dn))return void this.logger.info(`${this.name}: ${t} waits for organization ${i.orgId}`);this.logger.warn(`${this.name}: ${t} is not below its organization entry, so it is announced before organization ${i.orgId} is linked; clear its ${this.sentAttribute} and send it again once the organization is announced`)}const d=r(i.email);await this.announce(this.settings.twake_instance_auth_exchange,this.settings.twake_instance_user_created_key,{twakeId:i.twakeId,internalEmail:i.email,workplaceFqdn:a,...d?{matrixId:d}:{},...i.orgId?{organizationId:i.orgId}:{},...i.orgDomain?{domain:i.orgDomain,organizationDomain:i.orgDomain}:{},...i.phone?{mobile:i.phone}:{}}),await this.server.ldap.modify(t,{replace:{[this.sentAttribute]:(new Date).toISOString()}},n,o)}async organizationReady(t,e){const i=t.dn,a=first(this.value(t,this.organizationId));if(a&&this.provider instanceof s&&await this.provider.linkOrganization(a,e),first(this.value(t,this.sentAttribute)))return this.releaseMembers(i);this.organizationFqdn&&!first(this.value(t,this.organizationFqdn))&&await this.server.ldap.system.modify(i,{replace:{[this.organizationFqdn]:e}});const n=first(this.value(t,this.organizationDomain)),r=first(this.value(t,this.organizationName));await this.announce(this.settings.twake_instance_b2b_exchange,this.settings.twake_instance_organization_created_key,{organizationId:a,workplaceFqdn:e,...r?{organization:r}:{},...n?{domain:n}:{}}),await this.server.ldap.system.modify(i,{replace:{[this.sentAttribute]:(new Date).toISOString()}}),await this.releaseMembers(i)}async releaseMembers(t){const e=await this.server.ldap.system.search({paged:!1,scope:"sub",filter:`(&(${this.fqdnAttribute}=*)(!(${this.sentAttribute}=*)))`},t);let i=0;for(const t of e.searchEntries)try{await this.release(t)}catch(e){i++,this.logger.error(`${this.name}: ${t.dn} not released: ${String(e)}`)}if(i)throw new Error(`${this.name}: ${i} member(s) of ${t} not released`)}async release(t){const e=t.dn,i=this.account(e,t),a=first(this.value(t,this.fqdnAttribute));if(!i||!a)return;const n=a.toLowerCase()===this.provider.address(i)?{fqdn:a,ready:!0}:await this.provider.find(i,a);if(n?.ready)return this.settle(e,n.fqdn);this.logger.warn(`${this.name}: ${e} stays held, its address ${a} is not confirmed; ensureInstance sends it once it is`)}async serialized(t,e){const i=t.toLowerCase(),a=(this.queues.get(i)??Promise.resolve()).catch(()=>{}).then(e),n=a.catch(()=>{});return this.queues.set(i,n),n.then(()=>{this.queues.get(i)===n&&this.queues.delete(i)}),a}get organizationId(){return this.settings.twake_instance_organization_id_attribute}get organizationDomain(){return this.settings.twake_instance_organization_domain_attribute}get organizationName(){return this.settings.twake_instance_organization_name_attribute}get organizationFqdn(){return this.settings.twake_instance_organization_fqdn_attribute}organizationMail(t,e){return`${t}@${e}`.toLowerCase()}isOrganization(t){const e=this.settings.twake_instance_organization_base;return!!e&&d(t,e)}async storedOrganizationId(t){const e=await this.findOrganization(t).catch(()=>{});return e?l(e.dn):t}async storedAccount(t,e){const i=this.account(t,e);if(!i?.fromDn||!i.orgId)return i;const a=await this.storedOrganizationId(i.orgId);return a===i.orgId?i:{...i,orgId:a}}async findOrganization(t){const e=this.settings.twake_instance_organization_base;if(!e)return;return(await this.server.ldap.system.search({paged:!1,scope:"one",filter:`(${this.organizationId}=${u(t)})`},e)).searchEntries[0]}async read(t,e){try{return(await this.server.ldap.search({paged:!1,scope:"base"},t,e)).searchEntries[0]}catch(e){const i=c(e);if(32===i)return;if(34===i)throw new a(`Invalid DN: ${t}`);throw e}}async announce(t,e,i){const a=this.requirePlugin("rabbitmq");if(!a||!await a.getRawClient())throw new Error(`${this.name}: RabbitMQ unreachable, ${e} not sent`);await a.publish(t,e,i),this.logger.info({plugin:this.name,event:e,exchange:t})}account(t,e){const i=this.match(t);if(!i)return;const a=i.exec;if(_(e,this.lifecycle))return;const s=this.settings.twake_instance_skip_attribute;if(s&&first(this.value(e,s))?.toLowerCase()===this.settings.twake_instance_skip_value.toLowerCase())return;const r=first(this.value(e,"mail")),o=first(this.value(e,"uid"));if(!r||!o)return void this.logger.warn(`${this.name}: ${t} has no mail or uid, no instance`);const c=this.provider instanceof n,h=a.groups?.org&&g(a.groups.org),d=h||(c?this.settings.twake_instance_cozy_org_id:"")||void 0,l=a.groups?.org?r.split("@")[1]:c&&this.settings.twake_instance_cozy_org_domain||void 0,u=this.offers[i.index],w=this.id(o,a);if(w)return{id:w,...c?{oidc:o}:{},twakeId:o,email:r,publicName:first(this.value(e,"displayName"))||first(this.value(e,"cn"))||o,locale:first(this.value(e,"preferredLanguage"))||this.locale,phone:first(this.value(e,"mobile")),...h?{fromDn:!0}:{},...d?{orgId:d}:{},...l?{orgDomain:l}:{},...u?{offer:u}:{}};this.logger.warn(`${this.name}: ${t} gives no instance slug, no instance`)}match(t){let e;try{e=w(t)}catch{e=void 0}const i=this.separators(t);for(const[t,a]of this.patterns.entries()){const n=(void 0!==e?a.exec(e):null)??a.exec(i);if(n)return{exec:n,index:t}}}separators(t){let e="";for(let i=0;i<t.length;i++){if("\\"!==t[i]||i+1===t.length){e+=t[i];continue}const a=t[i+1],n=t.slice(i+1,i+3);","===a?(e+="\\2C",i++):/^[0-9a-f]{2}$/i.test(n)&&44===parseInt(n,16)?(e+="\\2C",i+=2):(e+=t.slice(i,i+2),i++)}return e}uid(t){try{return f(t).includes("uid")?l(t):""}catch{return""}}key(t){try{return m(t)}catch{return t.toLowerCase()}}id(t,e){const i={uid:t,...e.groups};return o(this.settings.twake_instance_id.replace(/\{(\w+)\}/g,(t,e)=>i[e]??""))}value(t,e){const i=e.toLowerCase(),a=Object.keys(t).find(t=>t.toLowerCase()===i);return a?t[a]:void 0}async findByMail(t,e){return(await this.server.ldap.search({paged:!1,scope:"sub",filter:`(mail=${u(t)})`},this.config.ldap_base,e)).searchEntries.find(t=>this.match(t.dn))}}export{k as EnsureInstanceBody,b as EnsureInstanceResponse,TwakeInstances as default};
@@ -1 +1 @@
1
- import{DEFAULT_LOCK_ATTRIBUTE as e,resolveLockConfig as t,holdsValue as r}from"../scim/mapping.js";import{scimOptionsOf as l}from"../scim/scim.options.js";import{twakeLifecycleOptionsOf as i}from"./twakeLifecycle.options.js";function lifecycleAttributes(r){const o=i(r),c=l(r),a=o.twake_lifecycle_deleted_at_format;if("iso8601"!==a&&"generalizedTime"!==a)throw new Error(`--twake-lifecycle-deleted-at-format must be iso8601 or generalizedTime, got '${a}'`);const n=o.twake_lifecycle_lock_attribute.trim(),u=c.scim_user_lock_attribute.trim()||e,f=t(n||u,o.twake_lifecycle_lock_value.trim()||(n&&n.toLowerCase()!==u.toLowerCase()?"":c.scim_user_lock_value),{attribute:"--twake-lifecycle-lock-attribute",value:"--twake-lifecycle-lock-value"});return{role:o.twake_lifecycle_role_attribute,lock:f.attribute,lockValue:f.value,deleted:o.twake_lifecycle_deleted_attribute,deletedValue:o.twake_lifecycle_deleted_value,deletedAt:o.twake_lifecycle_deleted_at_attribute,deletedAtFormat:a,reason:o.twake_lifecycle_reason_attribute}}function valueOf(e,t){if(!t)return;if(t in e)return e[t];const r=t.toLowerCase();for(const t of Object.keys(e))if(t.toLowerCase()===r)return e[t]}function first(e){const t=Array.isArray(e)?e[0]:e;if(null==t)return;const r=Buffer.isBuffer(t)?t.toString():String(t);return""===r?void 0:r}const isTombstone=(e,t)=>r(e,t.deleted,t.deletedValue),isLocked=(e,t)=>r(e,t.lock,t.lockValue);function formatDeletedAt(e,t){const r=e.toISOString();return"iso8601"===t?r:r.replace(/[-:T]/g,"").replace(/\.\d+Z$/,"Z")}function parseDeletedAt(e,t){if("iso8601"===t){const t=new Date(e);return isNaN(t.getTime())?void 0:t}const r=/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})?(?:[.,](\d+))?Z$/.exec(e);if(r)return new Date(Date.UTC(+r[1],+r[2]-1,+r[3],+r[4],+r[5],+(r[6]||0),Math.round(1e3*+`0.${r[7]||0}`)))}export{first,formatDeletedAt,isLocked,isTombstone,lifecycleAttributes,parseDeletedAt,valueOf};
1
+ import{parseDirectoryDate as e}from"../../lib/utils.js";import{DEFAULT_LOCK_ATTRIBUTE as t,resolveLockConfig as r,holdsValue as l}from"../scim/mapping.js";import{scimOptionsOf as i}from"../scim/scim.options.js";import{twakeLifecycleOptionsOf as o}from"./twakeLifecycle.options.js";function lifecycleAttributes(e){const l=o(e),c=i(e),a=l.twake_lifecycle_deleted_at_format;if("iso8601"!==a&&"generalizedTime"!==a)throw new Error(`--twake-lifecycle-deleted-at-format must be iso8601 or generalizedTime, got '${a}'`);const u=l.twake_lifecycle_lock_attribute.trim(),n=c.scim_user_lock_attribute.trim()||t,f=r(u||n,l.twake_lifecycle_lock_value.trim()||(u&&u.toLowerCase()!==n.toLowerCase()?"":c.scim_user_lock_value),{attribute:"--twake-lifecycle-lock-attribute",value:"--twake-lifecycle-lock-value"});return{role:l.twake_lifecycle_role_attribute,lock:f.attribute,lockValue:f.value,deleted:l.twake_lifecycle_deleted_attribute,deletedValue:l.twake_lifecycle_deleted_value,deletedAt:l.twake_lifecycle_deleted_at_attribute,deletedAtFormat:a,reason:l.twake_lifecycle_reason_attribute}}function valueOf(e,t){if(!t)return;if(t in e)return e[t];const r=t.toLowerCase();for(const t of Object.keys(e))if(t.toLowerCase()===r)return e[t]}function first(e){const t=Array.isArray(e)?e[0]:e;if(null==t)return;const r=Buffer.isBuffer(t)?t.toString():String(t);return""===r?void 0:r}const isTombstone=(e,t)=>l(e,t.deleted,t.deletedValue),isLocked=(e,t)=>l(e,t.lock,t.lockValue);function formatDeletedAt(e,t){const r=e.toISOString();return"iso8601"===t?r:r.replace(/[-:T]/g,"").replace(/\.\d+Z$/,"Z")}const c=/^\d{10}(?:\d{2}(?:\d{2})?)?(?:[.,]\d+)?(?:Z|[+-]\d{2}(?::?\d{2})?)$/,a=/^(\d{4})-(\d{2})-(\d{2})(?:T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/;function parseDeletedAt(t){const r=t.trim(),l=a.exec(r);if(l){if(!function(e,t,r){if(t<1||t>12||r<1)return!1;const l=new Date(0);return l.setUTCFullYear(e,t-1,r),l.getUTCMonth()===t-1&&l.getUTCDate()===r}(Number(l[1]),Number(l[2]),Number(l[3])))return}else if(!c.test(r))return;return e(r)??void 0}export{first,formatDeletedAt,isLocked,isTombstone,lifecycleAttributes,parseDeletedAt,valueOf};
@@ -1 +1 @@
1
- import{randomUUID as e}from"node:crypto";import t from"node:fs";import r from"../../abstract/plugin.js";import{extractLdapCode as s}from"../../lib/ldapCodes.js";import{unescapeDnValue as i,isDummyMemberDn as n,parseDn as o,normalizeDn as a}from"../../lib/utils.js";import{DEFAULT_LOCK_ATTRIBUTE as c,DEFAULT_LOCK_VALUE as l}from"../scim/mapping.js";import{lifecycleAttributes as h,isTombstone as u,isLocked as d,valueOf as f,first as m,parseDeletedAt as b}from"./lifecycleAttributes.js";import{twakeLifecycleOptions as g}from"./twakeLifecycle.options.js";const p=["created","roleChanged","disabled","enabled","updated","memberAdded","memberRemoved","deleted"],y=["$members","$added","$removed"];const isObject=e=>"object"==typeof e&&null!==e&&!Array.isArray(e);function optionalString(e,t,r){if(void 0!==e&&"string"!=typeof e)throw new Error(`${t}: "${r}" must be a string`)}const isStrings=e=>isObject(e)&&Object.values(e).every(e=>"string"==typeof e);function optionalCondition(e,t,r){if(void 0!==e){if(!isStrings(e))throw new Error(`${t}: "${r}" must be an object of strings`);return e}}const w=/^(?:[^$].*|\$(?!now$)[A-Za-z][A-Za-z0-9-]*)$/;function memberList(e){if(!isObject(e))return;const t=Object.entries(e);if(1!==t.length)return;const[r,s]=t[0];return y.includes(r)&&isStrings(s)&&Object.values(s).every(e=>w.test(e))?{list:r,fields:s}:void 0}function optionalPayload(e,t,r){if(void 0===e)return;const s=isObject(e)?Object.entries(e):void 0,i={};for(const[e,t]of s||[]){const r="string"==typeof t?t:memberList(t);if(void 0===r)break;i[e]=r}if(!s||Object.keys(i).length!==s.length)throw new Error(`${t}: "${r}" must be an object of strings, or of ${y.join(", ")} each mapped to an object of member attributes ($attr) or plain values`);return i}function dnKey(e){try{return a(e)}catch{return e.toLowerCase()}}function ruleAttributes(e){const t=/^\$(?:previous\.|changed\.)?([A-Za-z][A-Za-z0-9-]*)(?:\|domain)?$/;return e.flatMap(e=>Object.values(e.targets).flat()).flatMap(e=>[...Object.values(e.payload),...Object.keys(e.when)]).flatMap(e=>"string"==typeof e&&t.exec(e)?.[1]||[]).filter(e=>"now"!==e)}function parseRules(e){if(!e.trim())return[];const r=/^\s*[[{]/.test(e)?e:t.readFileSync(e,"utf8"),s=JSON.parse(r);if(!Array.isArray(s))throw new Error("--twake-lifecycle-rules must be a JSON array of rules");return s.map(e=>{if(!isObject(e))throw new Error("Every lifecycle rule must be an object");if("string"!=typeof e.dn)throw new Error('Every lifecycle rule needs a "dn" pattern');const{dn:t}=e,r=`Lifecycle rule ${t}`;optionalString(e.exchange,r,"exchange");const s=optionalPayload(e.payload,r,"payload");if(void 0!==e.events&&!isObject(e.events))throw new Error(`${r}: "events" must be an object`);const i={};for(const[r,n]of Object.entries(e.events||{})){if(!p.includes(r))throw new Error(`Unknown lifecycle event "${r}"; known: ${p.join(", ")}`);const o=`Lifecycle event "${r}" of ${t}`;i[r]=(Array.isArray(n)?n:[n]).map(t=>{if("string"!=typeof t&&!isObject(t))throw new Error(`${o}: a target is a routing key or an object`);const r="string"==typeof t?{routingKey:t}:t;optionalString(r.routingKey,o,"routingKey"),optionalString(r.exchange,o,"exchange");const i=r.exchange||e.exchange,n=r.routingKey;if(!i||!n)throw new Error(`${o} needs an exchange and a routing key`);return{exchange:i,routingKey:n,payload:optionalPayload(r.payload,o,"payload")||s||{},when:optionalCondition(r.when,o,"when")||{}}})}return{dn:new RegExp(t,"i"),targets:i}})}class TwakeLifecycleEvents extends r{static optionDefinitions=g;name="twakeLifecycleEvents";roles=["consistency"];dependencies={onLdapChange:"core/ldap/onChange",rabbitmq:"core/rabbitmq"};rules;attrs;memberAttribute;constructor(e){super(e);const t=this.options(g);if(this.attrs=h(this.config),this.memberAttribute=t.twake_lifecycle_member_attribute,this.rules=parseRules(t.twake_lifecycle_rules),this.followedOperationalAttributes=[this.attrs.lock,...ruleAttributes(this.rules)],0===this.rules.length)this.logger.warn(`${this.name}: --twake-lifecycle-rules is empty, nothing will be published`);else if(!this.config.rabbitmq_url)throw new Error(`${this.name}: --twake-lifecycle-rules needs --rabbitmq-url`)}async assertComposition(){if(0!==this.rules.length&&!await(this.requirePlugin("rabbitmq")?.getRawClient()))throw new Error(`${this.name}: RabbitMQ at --rabbitmq-url cannot be reached`)}afterLoad(){const e=this.server.loadedPlugins.scim?.config;if(!e)return;const t=e.scim_user_lock_attribute?.trim()||c,r=e.scim_user_lock_value?.trim()||l,{lock:s,lockValue:i}=this.attrs;t.toLowerCase()===s.toLowerCase()&&r.toLowerCase()===i.toLowerCase()||this.logger.warn(`${this.name}: SCIM locks an account with ${t}: ${r}, these events read ${s}: ${i}, so a SCIM deactivation publishes no disabled`)}hooks={onLdapEntryChange:(e,t,r,s)=>this.onEntryChange(e,t,r,s),twakedeletionreplay:(e,t,r)=>this.replay(e,t,r)};async replay(e,t,r){const s=this.match(e);s&&u(t,this.attrs)&&await this.publish(s.rule,"deleted",{before:t,after:t,dn:s.groups,change:r})}match(e){const t=function(e){return o(e).map(e=>{const t=e.indexOf("=");if(-1===t)return e;const r=e.slice(t+1).trim();return`${e.slice(0,t).trim()}=${r.replace(/\\,/g,"\\2C")}`}).join(",")}(e);for(const e of this.rules){const r=e.dn.exec(t);if(!r)continue;const s={};for(const[e,t]of Object.entries(r.groups||{}))void 0!==t&&(s[e]=i(t));return{rule:e,groups:s}}}async onEntryChange(e,t,r,s){const i=this.match(e);if(!i||t&&u(t,this.attrs))return;const{rule:n}=i,o={before:t||{},after:r||t||{},dn:i.groups,change:s};if(!r)return this.publish(n,"deleted",o);if(u(r,this.attrs))return t?this.publish(n,"deleted",o):void 0;if(!t)return await this.publish(n,"created",o),void(d(r,this.attrs)&&await this.publish(n,"disabled",o));this.attrs.role&&!function(e,t){const set=e=>new Set((void 0===e?[]:Array.isArray(e)?e:[e]).map(e=>e.toString().toLowerCase())),r=set(e),s=set(t);return r.size===s.size&&[...r].every(e=>s.has(e))}(f(t,this.attrs.role),f(r,this.attrs.role))&&await this.publish(n,"roleChanged",o);const a=d(r,this.attrs);a!==d(t,this.attrs)&&await this.publish(n,a?"disabled":"enabled",o),await this.publish(n,"updated",o);const c=this.memberDiff(r,t),l=this.memberDiff(t,r);c.length&&await this.publish(n,"memberAdded",{...o,added:c}),l.length&&await this.publish(n,"memberRemoved",{...o,removed:l})}members(e){const t=f(e,this.memberAttribute);return(void 0===t?[]:Array.isArray(t)?t:[t]).map(e=>e.toString()).filter(e=>!n(e,this.config.group_dummy_user))}memberDiff(e,t){const r=new Set(this.members(t).map(dnKey));return this.members(e).filter(e=>!r.has(dnKey(e)))}async memberPayloads({list:e,fields:t},r){const s="$added"===e?r.added||[]:"$removed"===e?r.removed||[]:this.members(r.after),i=Object.values(t).filter(e=>e.startsWith("$")).map(e=>e.slice(1));this.attrs.deleted&&i.push(this.attrs.deleted);const n=[];for(const e of s){const r=await this.readMember(e,i);if(!r||u(r,this.attrs))continue;const s={};for(const[e,i]of Object.entries(t)){const t=i.startsWith("$")?m(f(r,i.slice(1))):i;void 0!==t&&(s[e]=t)}n.push(s)}return n}async readMember(e,t){try{const{searchEntries:r}=await this.server.ldap.system.search({paged:!1,scope:"base",attributes:t},e);if(r[0])return r[0]}catch(e){if(32!==s(e))throw e}const[r]=o(e),n=r?.indexOf("=")??-1;if(-1!==n)return{[r.slice(0,n).trim()]:i(r.slice(n+1).trim())}}resolve(e,t){if(!e.startsWith("$"))return e;if("$now"===e)return(new Date).toISOString();let r=e.slice(1);const s=r.endsWith("|domain");let i;s&&(r=r.slice(0,-7));let n=r;if(r.startsWith("dn."))i=t.dn[r.slice(3)],n="";else if(r.startsWith("context."))i=t.change[r.slice(8)],n="";else if(r.startsWith("previous."))n=r.slice(9),i=m(f(t.before,n));else if(r.startsWith("changed.")){n=r.slice(8);const e=f(t.after,n);(function(e,t){const list=e=>(void 0===e?[]:Array.isArray(e)?e:[e]).map(e=>e.toString()).sort();return list(e).join("\0")===list(t).join("\0")})(f(t.before,n),e)||(i=m(e)??"")}else i=m(f(t.after,n));return void 0!==i&&this.attrs.deletedAt&&n.toLowerCase()===this.attrs.deletedAt.toLowerCase()&&(i=b(i,this.attrs.deletedAtFormat)?.toISOString()),s&&(i=i?.split("@")[1]),i}async publish(t,r,s){const i=t.targets[r];if(!i?.length)return;const n=this.requirePlugin("rabbitmq");if(n)for(const t of i){if(!Object.entries(t.when).every(([e,t])=>{const r=this.resolve(e,s);return t.startsWith("!")?r!==t.slice(1):r===t}))continue;const i={};let o=!1,a=!1,c=!1;try{for(const[e,r]of Object.entries(t.payload)){if("string"!=typeof r){const t=await this.memberPayloads(r,s);t.length||"$members"===r.list||(c=!0),i[e]=t;continue}const t=this.resolve(r,s);r.startsWith("$changed.")&&(o=!0,void 0!==t&&(a=!0)),void 0!==t&&(i[e]=t)}}catch(e){this.logger.error({plugin:this.name,event:r,routingKey:t.routingKey,result:"members unreadable",error:String(e)});continue}if(c||o&&!a)continue;const l=e(),h={plugin:this.name,event:r,exchange:t.exchange,routingKey:t.routingKey,messageId:l};if(await n.getRawClient())try{await n.publish(t.exchange,t.routingKey,i,{messageId:l}),this.logger.info({...h,result:"published"})}catch(e){this.logger.error({...h,result:"error",error:String(e)})}else this.logger.error({...h,result:"no broker"})}}}export{p as LIFECYCLE_EVENTS,TwakeLifecycleEvents as default,parseRules};
1
+ import{randomUUID as e}from"node:crypto";import t from"node:fs";import r from"../../abstract/plugin.js";import{extractLdapCode as s}from"../../lib/ldapCodes.js";import{unescapeDnValue as i,isDummyMemberDn as n,parseDn as o,normalizeDn as a}from"../../lib/utils.js";import{DEFAULT_LOCK_ATTRIBUTE as c,DEFAULT_LOCK_VALUE as l}from"../scim/mapping.js";import{lifecycleAttributes as u,isTombstone as h,isLocked as d,valueOf as f,first as m,parseDeletedAt as b}from"./lifecycleAttributes.js";import{twakeLifecycleOptions as g}from"./twakeLifecycle.options.js";const p=["created","roleChanged","disabled","enabled","updated","memberAdded","memberRemoved","deleted"],y=["$members","$added","$removed"];const isObject=e=>"object"==typeof e&&null!==e&&!Array.isArray(e);function optionalString(e,t,r){if(void 0!==e&&"string"!=typeof e)throw new Error(`${t}: "${r}" must be a string`)}const isStrings=e=>isObject(e)&&Object.values(e).every(e=>"string"==typeof e);function optionalCondition(e,t,r){if(void 0!==e){if(!isStrings(e))throw new Error(`${t}: "${r}" must be an object of strings`);return e}}const w=/^(?:[^$].*|\$(?!now$)[A-Za-z][A-Za-z0-9-]*)$/;function memberList(e){if(!isObject(e))return;const t=Object.entries(e);if(1!==t.length)return;const[r,s]=t[0];return y.includes(r)&&isStrings(s)&&Object.values(s).every(e=>w.test(e))?{list:r,fields:s}:void 0}function optionalPayload(e,t,r){if(void 0===e)return;const s=isObject(e)?Object.entries(e):void 0,i={};for(const[e,t]of s||[]){const r="string"==typeof t?t:memberList(t);if(void 0===r)break;i[e]=r}if(!s||Object.keys(i).length!==s.length)throw new Error(`${t}: "${r}" must be an object of strings, or of ${y.join(", ")} each mapped to an object of member attributes ($attr) or plain values`);return i}function dnKey(e){try{return a(e)}catch{return e.toLowerCase()}}function ruleAttributes(e){const t=/^\$(?:previous\.|changed\.)?([A-Za-z][A-Za-z0-9-]*)(?:\|domain)?$/;return e.flatMap(e=>Object.values(e.targets).flat()).flatMap(e=>[...Object.values(e.payload),...Object.keys(e.when)]).flatMap(e=>"string"==typeof e&&t.exec(e)?.[1]||[]).filter(e=>"now"!==e)}function parseRules(e){if(!e.trim())return[];const r=/^\s*[[{]/.test(e)?e:t.readFileSync(e,"utf8"),s=JSON.parse(r);if(!Array.isArray(s))throw new Error("--twake-lifecycle-rules must be a JSON array of rules");return s.map(e=>{if(!isObject(e))throw new Error("Every lifecycle rule must be an object");if("string"!=typeof e.dn)throw new Error('Every lifecycle rule needs a "dn" pattern');const{dn:t}=e,r=`Lifecycle rule ${t}`;optionalString(e.exchange,r,"exchange");const s=optionalPayload(e.payload,r,"payload");if(void 0!==e.events&&!isObject(e.events))throw new Error(`${r}: "events" must be an object`);const i={};for(const[r,n]of Object.entries(e.events||{})){if(!p.includes(r))throw new Error(`Unknown lifecycle event "${r}"; known: ${p.join(", ")}`);const o=`Lifecycle event "${r}" of ${t}`;i[r]=(Array.isArray(n)?n:[n]).map(t=>{if("string"!=typeof t&&!isObject(t))throw new Error(`${o}: a target is a routing key or an object`);const r="string"==typeof t?{routingKey:t}:t;optionalString(r.routingKey,o,"routingKey"),optionalString(r.exchange,o,"exchange");const i=r.exchange||e.exchange,n=r.routingKey;if(!i||!n)throw new Error(`${o} needs an exchange and a routing key`);return{exchange:i,routingKey:n,payload:optionalPayload(r.payload,o,"payload")||s||{},when:optionalCondition(r.when,o,"when")||{}}})}return{dn:new RegExp(t,"i"),targets:i}})}class TwakeLifecycleEvents extends r{static optionDefinitions=g;name="twakeLifecycleEvents";roles=["consistency"];dependencies={onLdapChange:"core/ldap/onChange",rabbitmq:"core/rabbitmq"};rules;attrs;memberAttribute;constructor(e){super(e);const t=this.options(g);if(this.attrs=u(this.config),this.memberAttribute=t.twake_lifecycle_member_attribute,this.rules=parseRules(t.twake_lifecycle_rules),this.followedOperationalAttributes=[this.attrs.lock,...ruleAttributes(this.rules)],0===this.rules.length)this.logger.warn(`${this.name}: --twake-lifecycle-rules is empty, nothing will be published`);else if(!this.config.rabbitmq_url)throw new Error(`${this.name}: --twake-lifecycle-rules needs --rabbitmq-url`)}async assertComposition(){if(0!==this.rules.length&&!await(this.requirePlugin("rabbitmq")?.getRawClient()))throw new Error(`${this.name}: RabbitMQ at --rabbitmq-url cannot be reached`)}afterLoad(){const e=this.server.loadedPlugins.scim?.config;if(!e)return;const t=e.scim_user_lock_attribute?.trim()||c,r=e.scim_user_lock_value?.trim()||l,{lock:s,lockValue:i}=this.attrs;t.toLowerCase()===s.toLowerCase()&&r.toLowerCase()===i.toLowerCase()||this.logger.warn(`${this.name}: SCIM locks an account with ${t}: ${r}, these events read ${s}: ${i}, so a SCIM deactivation publishes no disabled`)}hooks={onLdapEntryChange:(e,t,r,s)=>this.onEntryChange(e,t,r,s),twakedeletionreplay:(e,t,r)=>this.replay(e,t,r)};async replay(e,t,r){const s=this.match(e);s&&h(t,this.attrs)&&await this.publish(s.rule,"deleted",{before:t,after:t,dn:s.groups,change:r})}match(e){const t=function(e){return o(e).map(e=>{const t=e.indexOf("=");if(-1===t)return e;const r=e.slice(t+1).trim();return`${e.slice(0,t).trim()}=${r.replace(/\\,/g,"\\2C")}`}).join(",")}(e);for(const e of this.rules){const r=e.dn.exec(t);if(!r)continue;const s={};for(const[e,t]of Object.entries(r.groups||{}))void 0!==t&&(s[e]=i(t));return{rule:e,groups:s}}}async onEntryChange(e,t,r,s){const i=this.match(e);if(!i||t&&h(t,this.attrs))return;const{rule:n}=i,o={before:t||{},after:r||t||{},dn:i.groups,change:s};if(!r)return this.publish(n,"deleted",o);if(h(r,this.attrs))return t?this.publish(n,"deleted",o):void 0;if(!t)return await this.publish(n,"created",o),void(d(r,this.attrs)&&await this.publish(n,"disabled",o));this.attrs.role&&!function(e,t){const set=e=>new Set((void 0===e?[]:Array.isArray(e)?e:[e]).map(e=>e.toString().toLowerCase())),r=set(e),s=set(t);return r.size===s.size&&[...r].every(e=>s.has(e))}(f(t,this.attrs.role),f(r,this.attrs.role))&&await this.publish(n,"roleChanged",o);const a=d(r,this.attrs);a!==d(t,this.attrs)&&await this.publish(n,a?"disabled":"enabled",o),await this.publish(n,"updated",o);const c=this.memberDiff(r,t),l=this.memberDiff(t,r);c.length&&await this.publish(n,"memberAdded",{...o,added:c}),l.length&&await this.publish(n,"memberRemoved",{...o,removed:l})}members(e){const t=f(e,this.memberAttribute);return(void 0===t?[]:Array.isArray(t)?t:[t]).map(e=>e.toString()).filter(e=>!n(e,this.config.group_dummy_user))}memberDiff(e,t){const r=new Set(this.members(t).map(dnKey));return this.members(e).filter(e=>!r.has(dnKey(e)))}async memberPayloads({list:e,fields:t},r){const s="$added"===e?r.added||[]:"$removed"===e?r.removed||[]:this.members(r.after),i=Object.values(t).filter(e=>e.startsWith("$")).map(e=>e.slice(1));this.attrs.deleted&&i.push(this.attrs.deleted);const n=[];for(const e of s){const r=await this.readMember(e,i);if(!r||h(r,this.attrs))continue;const s={};for(const[e,i]of Object.entries(t)){const t=i.startsWith("$")?m(f(r,i.slice(1))):i;void 0!==t&&(s[e]=t)}n.push(s)}return n}async readMember(e,t){try{const{searchEntries:r}=await this.server.ldap.system.search({paged:!1,scope:"base",attributes:t},e);if(r[0])return r[0]}catch(e){if(32!==s(e))throw e}const[r]=o(e),n=r?.indexOf("=")??-1;if(-1!==n)return{[r.slice(0,n).trim()]:i(r.slice(n+1).trim())}}resolve(e,t){if(!e.startsWith("$"))return e;if("$now"===e)return(new Date).toISOString();let r=e.slice(1);const s=r.endsWith("|domain");let i;s&&(r=r.slice(0,-7));let n=r;if(r.startsWith("dn."))i=t.dn[r.slice(3)],n="";else if(r.startsWith("context."))i=t.change[r.slice(8)],n="";else if(r.startsWith("previous."))n=r.slice(9),i=m(f(t.before,n));else if(r.startsWith("changed.")){n=r.slice(8);const e=f(t.after,n);(function(e,t){const list=e=>(void 0===e?[]:Array.isArray(e)?e:[e]).map(e=>e.toString()).sort();return list(e).join("\0")===list(t).join("\0")})(f(t.before,n),e)||(i=m(e)??"")}else i=m(f(t.after,n));return void 0!==i&&this.attrs.deletedAt&&n.toLowerCase()===this.attrs.deletedAt.toLowerCase()&&(i=b(i)?.toISOString()),s&&(i=i?.split("@")[1]),i}async publish(t,r,s){const i=t.targets[r];if(!i?.length)return;const n=this.requirePlugin("rabbitmq");if(n)for(const t of i){if(!Object.entries(t.when).every(([e,t])=>{const r=this.resolve(e,s);return t.startsWith("!")?r!==t.slice(1):r===t}))continue;const i={};let o=!1,a=!1,c=!1;try{for(const[e,r]of Object.entries(t.payload)){if("string"!=typeof r){const t=await this.memberPayloads(r,s);t.length||"$members"===r.list||(c=!0),i[e]=t;continue}const t=this.resolve(r,s);r.startsWith("$changed.")&&(o=!0,void 0!==t&&(a=!0)),void 0!==t&&(i[e]=t)}}catch(e){this.logger.error({plugin:this.name,event:r,routingKey:t.routingKey,result:"members unreadable",error:String(e)});continue}if(c||o&&!a)continue;const l=e(),u={plugin:this.name,event:r,exchange:t.exchange,routingKey:t.routingKey,messageId:l};if(await n.getRawClient())try{await n.publish(t.exchange,t.routingKey,i,{messageId:l}),this.logger.info({...u,result:"published"})}catch(e){this.logger.error({...u,result:"error",error:String(e)})}else this.logger.error({...u,result:"no broker"})}}}export{p as LIFECYCLE_EVENTS,TwakeLifecycleEvents as default,parseRules};
@@ -1 +1 @@
1
- import{Type as e}from"@sinclair/typebox";import t from"../../abstract/plugin.js";import{BadRequestError as s,NotFoundError as r,ConflictError as a}from"../../lib/errors.js";import{changeContext as o}from"../../lib/changeContext.js";import{extractLdapCode as i}from"../../lib/ldapCodes.js";import{escapeLdapFilter as n,isDnInBranch as h}from"../../lib/utils.js";import{scimOptionsOf as c}from"../scim/scim.options.js";import{lifecycleAttributes as l,isTombstone as d,first as f,valueOf as m,parseDeletedAt as p,formatDeletedAt as u}from"./lifecycleAttributes.js";import{twakeTombstoneOptions as w,twakeTombstoneOptionsOf as g}from"./twakeTombstone.options.js";import{defineRoute as b}from"../../lib/route.js";const y=/^[A-Za-z0-9_.-]{1,64}$/,_=e.Object({dn:e.String(),force:e.Optional(e.Boolean())}),k=e.Object({success:e.Boolean()});class TwakeTombstone extends t{static optionDefinitions=w;name="twakeTombstone";roles=["consistency","api"];attrs;patterns;defaultReason;reasonHeader;reasons;clearAttributes;eraseMinAge;groupBases;scimPrefix;reasonFor=new Map;erasing=new Set;constructor(e){super(e);const t=g(e.config);if(this.attrs=l(e.config),!this.attrs.deleted)throw new Error(`${this.name}: --twake-lifecycle-deleted-attribute is required`);this.patterns=t.twake_tombstone_dn.map(e=>new RegExp(e,"i")),this.reasons=t.twake_tombstone_reasons,this.defaultReason=this.checkReason(t.twake_tombstone_default_reason),this.reasonHeader=t.twake_tombstone_reason_header.toLowerCase(),this.clearAttributes=t.twake_tombstone_clear_attributes,this.eraseMinAge=t.twake_tombstone_erase_min_age,this.groupBases=t.twake_tombstone_group_bases.length?t.twake_tombstone_group_bases:[e.config.ldap_group_base||e.config.ldap_base||""],this.scimPrefix=c(e.config).scim_prefix,0===this.patterns.length&&this.logger.warn(`${this.name}: --twake-tombstone-dn is empty, every delete stays a delete`)}afterLoad(){for(const e of["ldapdeleterequest","ldapaddrequest"]){const t=this.hooks?.[e];t&&this.hookBus.toLast(e,t)}}hooks={ldapdeleterequest:async([e,t])=>{const s=[];for(const r of Array.isArray(e)?e:[e]){if(this.keeps(r)){const e=await this.read(r);if(e){await this.writeTombstone(r,e,this.reasonOf(r,t),t);continue}}s.push(r)}return[s,t]},ldapaddrequest:async([e,t,s])=>{if(this.isScim(s)&&this.matches(e)){const t=await this.read(e);t&&d(t,this.attrs)&&await this.replace(e,t,s)}return[e,t,s]},ldapsearchrequest:([e,t,s])=>{if(!this.isScim(s))return[e,t,s];const r=t.filter||"(objectClass=*)",a=`(!(${this.attrs.deleted}=${n(this.attrs.deletedValue)}))`;return[e,{...t,filter:`(&${String(r).startsWith("(")?String(r):`(${String(r)})`}${a})`},s]}};api(e){b(e,{method:"post",path:`${this.config.api_prefix}/v1/twake/tombstones/erase`,body:_,responses:{200:k},handler:async({req:e,res:t,body:s})=>{await this.erase(s.dn,{force:!0===s.force,req:e}),t.json({success:!0})}})}async tombstone(e,t,r){if(!this.matches(e))throw new s(`${e} is not an entry that becomes a tombstone`);this.reasonFor.set(e,this.checkReason(t));try{await this.server.ldap.delete(e,r)}finally{this.reasonFor.delete(e)}}async erase(e,{force:t=!1,req:s}={}){await this.server.ldap.authorize("ldapdeleterequest",[e,s]);const o=await this.read(e);if(!o||!d(o,this.attrs))throw new r(`No tombstone at ${e}`);if(!t){const t=f(m(o,this.attrs.deletedAt)),s=t&&p(t,this.attrs.deletedAtFormat);if(!s)throw new a(`${e} holds no readable deletion date (--twake-lifecycle-deleted-at-attribute); force it to erase now`);if(Date.now()-s.getTime()<1e3*this.eraseMinAge)throw new a(`${e} was deleted less than ${this.eraseMinAge} seconds ago; force it to erase now`)}this.erasing.add(e);try{await this.server.ldap.delete(e,s)}finally{this.erasing.delete(e)}await this.leaveGroups(e),this.logger.info({plugin:this.name,event:"erase",dn:e,force:t})}keeps(e){return!this.erasing.has(e)&&this.matches(e)}assertComposition(){if(!this.server.loadedPlugins.trash)return;const e=String(this.config.trash_watched_bases||"").split(";").map(e=>e.trim()).filter(Boolean),t=this.patterns.filter(t=>{const s=t.source.replace(/\$$/,"");return 0===e.length||e.some(e=>h(s,e)||h(e,s))});if(t.length)throw new Error(`${this.name}: core/ldap/trash watches the branch of ${t.map(e=>e.source).join(", ")}, and would move those entries instead of leaving a tombstone. Leave that branch out of --trash-watched-bases, or load one of the two plugins only`)}async replace(e,t,s){const r=await this.groupsOf(e),a=this.config.group_dummy_user,o=new Set(a?await this.groupsOf(a):[]);await this.erase(e,{force:!0,req:s});const i=s.res;if(!i)return;const n=i.end.bind(i);i.end=(...s)=>{i.end=n;const a=n(...s);return this.restore(e,t,r,o).catch(t=>this.logger.error({plugin:this.name,event:"restore",dn:e,error:String(t)})),a}}async restore(e,t,s,r){if(await this.read(e))return;const a=Object.fromEntries(Object.entries(t).filter(([e,t])=>"dn"!==e&&!(Array.isArray(t)&&0===t.length)));await this.server.ldap.system.add(e,a);const o=this.config.group_dummy_user;for(const t of s)await this.server.ldap.system.modify(t,{add:{member:e}}),o&&!r.has(t)&&await this.server.ldap.system.modify(t,{delete:{member:o}}).catch(e=>{if(16!==i(e))throw e});this.logger.warn({plugin:this.name,event:"restore",dn:e})}matches(e){return this.patterns.some(t=>t.test(e))}isScim(e){const t=e?.originalUrl??e?.url;return"string"==typeof t&&t.startsWith(this.scimPrefix)}checkReason(e){if(!(this.reasons.length?this.reasons.includes(e):y.test(e)))throw new s(`Unknown deletion reason "${e}"${this.reasons.length?`; known: ${this.reasons.join(", ")}`:""}`);return e}reasonOf(e,t){const s=this.reasonFor.get(e);if(s)return s;const r=t?.headers?.[this.reasonHeader],a=Array.isArray(r)?r[0]:r;return a?this.checkReason(a):this.defaultReason}async read(e){try{return(await this.server.ldap.system.search({paged:!1,scope:"base",attributes:["*",this.attrs.lock]},e)).searchEntries[0]}catch(e){if(32===i(e))return;throw e}}async writeTombstone(e,t,s,r){const{attrs:a}=this;if(d(t,a))return this.hookBus.notify("twakedeletionreplay",e,t,o(r)),void this.logger.info({plugin:this.name,event:"replay",dn:e});const i={[a.deleted]:a.deletedValue,[a.lock]:a.lockValue};a.deletedAt&&(i[a.deletedAt]=u(new Date,a.deletedAtFormat)),a.reason&&(i[a.reason]=s);const n=this.clearAttributes.filter(e=>void 0!==m(t,e));await this.server.ldap.modify(e,n.length?{replace:i,delete:n}:{replace:i},void 0,{context:o(r)}),this.logger.info({plugin:this.name,event:"tombstone",dn:e,reason:s})}async groupsOf(e){const t=[];for(const s of this.groupBases.filter(Boolean))try{const r=await this.server.ldap.system.search({paged:!1,scope:"sub",filter:`(member=${n(e)})`,attributes:["dn"]},s);t.push(...r.searchEntries.map(e=>e.dn))}catch(e){if(32!==i(e))throw e}return t}async leaveGroups(e){for(const t of await this.groupsOf(e))await this.leaveGroup(t,e)}async leaveGroup(e,t){const s=this.config.group_dummy_user;let r=!1;for(let a=0;;a++)try{return void await this.server.ldap.system.modify(e,r?{add:{member:s},delete:{member:t}}:{delete:{member:t}})}catch(e){const t=i(e);if(16===t)return;if(a>=2)throw e;if(65===t&&s)r=!0;else{if(20!==t)throw e;r=!1}}}}export{_ as EraseTombstoneBody,k as EraseTombstoneResponse,TwakeTombstone as default};
1
+ import{Type as e}from"@sinclair/typebox";import t from"../../abstract/plugin.js";import{BadRequestError as s,NotFoundError as r,ConflictError as a}from"../../lib/errors.js";import{changeContext as o}from"../../lib/changeContext.js";import{extractLdapCode as i}from"../../lib/ldapCodes.js";import{escapeLdapFilter as n,isDnInBranch as h,normalizeDn as c}from"../../lib/utils.js";import{scimOptionsOf as l}from"../scim/scim.options.js";import{lifecycleAttributes as d,isTombstone as f,first as m,valueOf as p,parseDeletedAt as u,formatDeletedAt as w}from"./lifecycleAttributes.js";import{twakeTombstoneOptions as g,twakeTombstoneOptionsOf as b}from"./twakeTombstone.options.js";import{defineRoute as y}from"../../lib/route.js";const _=/^[A-Za-z0-9_.-]{1,64}$/,k=e.Object({dn:e.String(),force:e.Optional(e.Boolean())}),v=e.Object({success:e.Boolean()});class TwakeTombstone extends t{static optionDefinitions=g;name="twakeTombstone";roles=["consistency","api"];attrs;patterns;defaultReason;reasonHeader;reasons;clearAttributes;eraseMinAge;groupBases;scimPrefix;reasonFor=new Map;erasing=new Set;constructor(e){super(e);const t=b(e.config);if(this.attrs=d(e.config),!this.attrs.deleted)throw new Error(`${this.name}: --twake-lifecycle-deleted-attribute is required`);this.patterns=t.twake_tombstone_dn.map(e=>new RegExp(e,"i")),this.reasons=t.twake_tombstone_reasons,this.defaultReason=this.checkReason(t.twake_tombstone_default_reason),this.reasonHeader=t.twake_tombstone_reason_header.toLowerCase(),this.clearAttributes=t.twake_tombstone_clear_attributes,this.eraseMinAge=t.twake_tombstone_erase_min_age,this.groupBases=t.twake_tombstone_group_bases.length?t.twake_tombstone_group_bases:[e.config.ldap_group_base||e.config.ldap_base||""],this.scimPrefix=l(e.config).scim_prefix,0===this.patterns.length&&this.logger.warn(`${this.name}: --twake-tombstone-dn is empty, every delete stays a delete`)}afterLoad(){for(const e of["ldapdeleterequest","ldapaddrequest"]){const t=this.hooks?.[e];t&&this.hookBus.toLast(e,t)}}hooks={ldapdeleterequest:async([e,t])=>{const s=[];for(const r of Array.isArray(e)?e:[e]){if(!this.erasing.has(r)){const e=await this.read(r);if(e&&(this.matches(r)||this.matches(e.dn))){await this.writeTombstone(r,e,this.reasonOf(r,t),t);continue}}s.push(r)}return[s,t]},ldapaddrequest:async([e,t,s])=>{if(this.isScim(s)&&this.matches(e)){const t=await this.read(e);t&&f(t,this.attrs)&&await this.replace(e,t,s)}return[e,t,s]},ldapsearchrequest:([e,t,s])=>{if(!this.isScim(s))return[e,t,s];const r=t.filter||"(objectClass=*)",a=`(!(${this.attrs.deleted}=${n(this.attrs.deletedValue)}))`;return[e,{...t,filter:`(&${String(r).startsWith("(")?String(r):`(${String(r)})`}${a})`},s]}};api(e){y(e,{method:"post",path:`${this.config.api_prefix}/v1/twake/tombstones/erase`,body:k,responses:{200:v},handler:async({req:e,res:t,body:s})=>{await this.erase(s.dn,{force:!0===s.force,req:e}),t.json({success:!0})}})}async tombstone(e,t,r){if(!this.matches(e))throw new s(`${e} is not an entry that becomes a tombstone`);this.reasonFor.set(e,this.checkReason(t));try{await this.server.ldap.delete(e,r)}finally{this.reasonFor.delete(e)}}async erase(e,{force:t=!1,req:s}={}){await this.server.ldap.authorize("ldapdeleterequest",[e,s]);const o=await this.read(e);if(!o||!f(o,this.attrs))throw new r(`No tombstone at ${e}`);if(!t){const t=m(p(o,this.attrs.deletedAt)),s=t&&u(t);if(!s)throw new a(`${e} holds no readable deletion date (--twake-lifecycle-deleted-at-attribute); force it to erase now`);if(Date.now()-s.getTime()<1e3*this.eraseMinAge)throw new a(`${e} was deleted less than ${this.eraseMinAge} seconds ago; force it to erase now`)}this.erasing.add(e);try{await this.server.ldap.delete(e,s)}finally{this.erasing.delete(e)}await this.leaveGroups(e),this.logger.info({plugin:this.name,event:"erase",dn:e,force:t})}keeps(e){return!this.erasing.has(e)&&this.matches(e)}assertComposition(){if(!this.server.loadedPlugins.trash)return;const e=String(this.config.trash_watched_bases||"").split(";").map(e=>e.trim()).filter(Boolean),t=this.patterns.filter(t=>{const s=t.source.replace(/\$$/,"");return 0===e.length||e.some(e=>h(s,e)||h(e,s))});if(t.length)throw new Error(`${this.name}: core/ldap/trash watches the branch of ${t.map(e=>e.source).join(", ")}, and would move those entries instead of leaving a tombstone. Leave that branch out of --trash-watched-bases, or load one of the two plugins only`)}async replace(e,t,s){const r=await this.groupsOf(e),a=this.config.group_dummy_user,o=new Set(a?await this.groupsOf(a):[]);await this.erase(e,{force:!0,req:s});const i=s.res;if(!i)return;const n=i.end.bind(i);i.end=(...s)=>{i.end=n;const a=n(...s);return this.restore(e,t,r,o).catch(t=>this.logger.error({plugin:this.name,event:"restore",dn:e,error:String(t)})),a}}async restore(e,t,s,r){if(await this.read(e))return;const a=Object.fromEntries(Object.entries(t).filter(([e,t])=>"dn"!==e&&!(Array.isArray(t)&&0===t.length)));await this.server.ldap.system.add(e,a);const o=this.config.group_dummy_user;for(const t of s)await this.server.ldap.system.modify(t,{add:{member:e}}),o&&!r.has(t)&&await this.server.ldap.system.modify(t,{delete:{member:o}}).catch(e=>{if(16!==i(e))throw e});this.logger.warn({plugin:this.name,event:"restore",dn:e})}matches(e){if(this.patterns.some(t=>t.test(e)))return!0;let t;try{t=c(e)}catch{return!1}return this.patterns.some(e=>e.test(t))}isScim(e){const t=e?.originalUrl??e?.url;return"string"==typeof t&&t.startsWith(this.scimPrefix)}checkReason(e){if(!(this.reasons.length?this.reasons.includes(e):_.test(e)))throw new s(`Unknown deletion reason "${e}"${this.reasons.length?`; known: ${this.reasons.join(", ")}`:""}`);return e}reasonOf(e,t){const s=this.reasonFor.get(e);if(s)return s;const r=t?.headers?.[this.reasonHeader],a=Array.isArray(r)?r[0]:r;return a?this.checkReason(a):this.defaultReason}async read(e){try{return(await this.server.ldap.system.search({paged:!1,scope:"base",attributes:["*",this.attrs.lock]},e)).searchEntries[0]}catch(t){const r=i(t);if(32===r)return;if(34===r)throw new s(`Invalid DN: ${e}`);throw t}}async writeTombstone(e,t,s,r){const{attrs:a}=this;if(f(t,a))return this.hookBus.notify("twakedeletionreplay",e,t,o(r)),void this.logger.info({plugin:this.name,event:"replay",dn:e});const i={[a.deleted]:a.deletedValue,[a.lock]:a.lockValue};a.deletedAt&&(i[a.deletedAt]=w(new Date,a.deletedAtFormat)),a.reason&&(i[a.reason]=s);const n=this.clearAttributes.filter(e=>void 0!==p(t,e));await this.server.ldap.modify(e,n.length?{replace:i,delete:n}:{replace:i},void 0,{context:o(r)}),this.logger.info({plugin:this.name,event:"tombstone",dn:e,reason:s})}async groupsOf(e){const t=[];for(const s of this.groupBases.filter(Boolean))try{const r=await this.server.ldap.system.search({paged:!1,scope:"sub",filter:`(member=${n(e)})`,attributes:["dn"]},s);t.push(...r.searchEntries.map(e=>e.dn))}catch(e){if(32!==i(e))throw e}return t}async leaveGroups(e){for(const t of await this.groupsOf(e))await this.leaveGroup(t,e)}async leaveGroup(e,t){const s=this.config.group_dummy_user;let r=!1;for(let a=0;;a++)try{return void await this.server.ldap.system.modify(e,r?{add:{member:s},delete:{member:t}}:{delete:{member:t}})}catch(e){const t=i(e);if(16===t)return;if(a>=2)throw e;if(65===t&&s)r=!0;else{if(20!==t)throw e;r=!1}}}}export{k as EraseTombstoneBody,v as EraseTombstoneResponse,TwakeTombstone as default};
@@ -382,6 +382,19 @@ export default abstract class LdapFlat extends DmPlugin {
382
382
  * @throws BadRequestError if the provided DN is not a direct child of this.base
383
383
  */
384
384
  protected resolveDn(id: string): string;
385
+ /**
386
+ * The name and the DN a creation of `id` gives: `id` is the naming value,
387
+ * or a DN whose first RDN names the entry, folded as the directory folds
388
+ * it and written back in this branch.
389
+ *
390
+ * Both the write and the answer it gets after it read the DN here, so a
391
+ * spelling the directory folds differently cannot leave the entry created
392
+ * under one DN and read back at another.
393
+ */
394
+ protected creationOf(id: string): {
395
+ name: string;
396
+ dn: string;
397
+ };
385
398
  addEntry(id: string, additional?: AttributesList, req?: Request): Promise<boolean>;
386
399
  modifyEntry(id: string, changes: ModifyRequest, req?: Request): Promise<boolean>;
387
400
  /**
@@ -8,9 +8,12 @@ import type { Config, DM } from '../bin';
8
8
  import type { Hooks, MaybePromise } from '../hooks';
9
9
  import type HookBus from '../lib/hookBus';
10
10
  import type { HealthStatus } from '../lib/health';
11
+ import { type ServiceProvider, type ServiceToken } from '../lib/services';
11
12
  import { type OptionsOf, type OptionSet } from '../config/options';
12
13
  export { asyncHandler, escapeDnValue, unescapeDnValue, escapeLdapFilter, validateDnValue, } from '../lib/utils';
13
14
  export { defineRoute, registeredRoutes } from '../lib/route';
15
+ export { serviceToken } from '../lib/services';
16
+ export type { ServiceProvider, ServiceToken } from '../lib/services';
14
17
  export { HttpError, BadRequestError, UnauthorizedError, ForbiddenError, NotFoundError, ConflictError, UriTooLongError, TooManyRequestsError, BadGatewayError, ServiceUnavailableError, GatewayTimeoutError, } from '../lib/errors';
15
18
  export type { HealthStatus };
16
19
  export type Role = 'auth' | 'authz' | 'protect' | 'api' | 'logging' | 'demo' | 'consistency' | 'configurable';
@@ -30,6 +33,12 @@ export default abstract class DmPlugin {
30
33
  */
31
34
  hooks?: Hooks;
32
35
  dependencies?: Record<string, string>;
36
+ /**
37
+ * Services this plugin provides, each bound to its token. A consumer
38
+ * reaches them with `service(token)`, see
39
+ * `docs/plugin-development/services.md`.
40
+ */
41
+ provides?: ServiceProvider<unknown>[];
33
42
  roles?: Role[] | undefined;
34
43
  /**
35
44
  * Operational attributes this plugin follows through `onLdapEntryChange`,
@@ -111,6 +120,18 @@ export default abstract class DmPlugin {
111
120
  protected hideNeverReturn<T>(data: T): T;
112
121
  /** Names already warned about by requirePlugin, so each warns at most once. */
113
122
  private missingPluginsWarned?;
123
+ /** Services already warned about by service(), so each warns at most once. */
124
+ private missingServicesWarned?;
125
+ /**
126
+ * Reach a service another plugin provides. Declare the providing plugin in
127
+ * `dependencies` so it registers first. Returns null and logs a single
128
+ * warning when nothing provides it, so the consumer skips its feature
129
+ * rather than throwing.
130
+ *
131
+ * @param token the service wanted
132
+ * @returns the provided instance, or null
133
+ */
134
+ protected service<T>(token: ServiceToken<T>): T | null;
114
135
  /**
115
136
  * Resolve a sibling plugin by its registry name (the value of its `name`
116
137
  * property). Declare it in `dependencies` so it loads first. Returns null and
@@ -19,6 +19,7 @@ import type { Hooks } from '../hooks';
19
19
  import ldapActions from '../lib/ldapActions';
20
20
  import type DmPlugin from '../abstract/plugin';
21
21
  import AuthBase from '../lib/auth/base';
22
+ import { type ServiceMap } from '../lib/services';
22
23
  import HookBus from '../lib/hookBus';
23
24
  export type { Config };
24
25
  export * from '../lib/utils';
@@ -44,8 +45,22 @@ export declare class DM {
44
45
  loadedPlugins: {
45
46
  [key: string]: DmPlugin;
46
47
  };
48
+ /**
49
+ * The services plugins provide, keyed by their token. Shared by `withConfig`
50
+ * views as the plugin registry is; `registerPlugin` fills it.
51
+ */
52
+ readonly services: ServiceMap;
53
+ /** The plugin that provided each service, for the duplicate warning. */
54
+ private readonly serviceOwners;
47
55
  ldap: ldapActions;
48
- operationSequence: number;
56
+ /**
57
+ * The next operation number, shared by the `withConfig` views. An accessor
58
+ * over an object because a field would be copied into each view — which
59
+ * would then count on its own — while an object is copied by reference.
60
+ */
61
+ private readonly _operations;
62
+ get operationSequence(): number;
63
+ set operationSequence(value: number);
49
64
  logger: winston.Logger;
50
65
  /** Authentication plugins, in registration order */
51
66
  authenticators: AuthBase[];
@@ -58,15 +73,25 @@ export declare class DM {
58
73
  * shared.
59
74
  */
60
75
  private _authState;
61
- private _errorMiddlewareSetup;
76
+ /**
77
+ * The mounted error middleware, if any. In an object, as `_authState` is: a
78
+ * view made during loading would copy a bare `undefined` field, so a route
79
+ * it registers after `ready` would land behind the handler — the one place
80
+ * Express never routes an error to.
81
+ */
82
+ private readonly _errorState;
62
83
  /** The parsers plugins mount ahead of the global ones, see registerBodyParser */
63
84
  private readonly bodyParsers;
64
85
  /**
65
86
  * Whether the plugins of the configuration are loaded and their
66
87
  * composition checked. A plugin registered after that — a server
67
88
  * assembled by hand — is checked on its own as it arrives.
89
+ *
90
+ * In an object, as `_authState` is: a plain flag is copied into each
91
+ * `withConfig` view, and a view made during loading would keep `false` and
92
+ * let a plugin registered through it skip the composition check.
68
93
  */
69
- private _composed;
94
+ private readonly _compositionState;
70
95
  /** Every plugin loaded and the composition checked */
71
96
  get composed(): boolean;
72
97
  /**
@@ -82,7 +107,6 @@ export declare class DM {
82
107
  get stopping(): boolean;
83
108
  /** Rejections nobody handled since the start, logged and counted */
84
109
  get unhandledRejections(): number;
85
- private _errorMiddleware?;
86
110
  /**
87
111
  * The loads of `--plugin` entries without overrides, keyed by resolved
88
112
  * module and name. Regular plugins load in parallel and load their own
@@ -94,6 +118,8 @@ export declare class DM {
94
118
  private readonly pluginLoads;
95
119
  /** CLI arguments no declared option matched, reported after the logger exists */
96
120
  private readonly unknownCliOptions;
121
+ /** Configuration read through a deprecated name, reported once the logger exists */
122
+ private readonly deprecationWarnings;
97
123
  /**
98
124
  * The request hooks of the authorization plugins, which `registerPlugin`
99
125
  * keeps ahead of every other plugin's. See {@link AUTHZ_FIRST_HOOKS}.
@@ -296,4 +322,12 @@ export declare class DM {
296
322
  */
297
323
  private admitToComposition;
298
324
  registerPlugin(pluginName: string, obj: DmPlugin, name?: string): Promise<boolean>;
325
+ /**
326
+ * Record the services a plugin provides. The first provider of a token
327
+ * wins: a second one would answer for the same interface and leave the
328
+ * choice to the load order, so it is ignored and named in a warning.
329
+ *
330
+ * @param obj the plugin just registered
331
+ */
332
+ private registerServices;
299
333
  }
@@ -24,6 +24,17 @@ export interface OptionSpec<T> {
24
24
  * reading the declared default, as an empty environment or CLI value does.
25
25
  */
26
26
  readonly emptyIsValue?: boolean;
27
+ /**
28
+ * Former CLI names still accepted, without the leading `--`: the parser
29
+ * reads them as the canonical name and warns at startup. An alias takes one
30
+ * value per occurrence, as the singular form does.
31
+ */
32
+ readonly aliases?: readonly string[];
33
+ /**
34
+ * Former environment variables still accepted, read when `env` is unset or
35
+ * holds an empty value the type reads as nothing
36
+ */
37
+ readonly envAliases?: readonly string[];
27
38
  }
28
39
  export interface StringOption extends OptionSpec<string> {
29
40
  readonly type: 'string';
@@ -85,5 +96,9 @@ export declare function unknownEnvironmentOptions(entries: ConfigTemplate, env?:
85
96
  * Read a plugin's options from a parsed configuration, applying the declared
86
97
  * defaults and checking the values. Called once per instance by
87
98
  * `DmPlugin.options()`.
99
+ *
100
+ * `parsedKeys` names the options the parser knew. An option declared outside
101
+ * `src/config/args.ts` reaches the parser as an unknown `--name value` pair,
102
+ * so its aliases are read here, under the key the unknown name produces.
88
103
  */
89
- export declare function readOptions<T extends OptionSet>(options: T, config: Config | Record<string, unknown>, parsedKeys?: ReadonlySet<string>): OptionsOf<T>;
104
+ export declare function readOptions<T extends OptionSet>(options: T, config: Config | Record<string, unknown>, parsedKeys?: ReadonlySet<string>, warn?: (message: string) => void): OptionsOf<T>;
@@ -108,7 +108,8 @@ export declare const isAuthzRefusal: (err: unknown) => boolean;
108
108
  *
109
109
  * The first error from the outside that names one decides: a wrapper names
110
110
  * none, so the error it wraps is read; a layer that translates an inner error
111
- * keeps its own status.
111
+ * keeps its own status. `statusCode` is the convention of this module, while
112
+ * Express's router sets `status` alone on the errors it throws.
112
113
  *
113
114
  * @param err what was thrown
114
115
  * @returns its status code, 500 when it names none
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The directory as one caller sees it: whether a route may tell them an
3
+ * entry exists, and what the `ldapsearchfilter` hooks leave of a result.
4
+ */
5
+ import type { Request } from 'express';
6
+ import type { SearchOptions, SearchResult } from 'ldapts';
7
+ export interface CallerViewOptions {
8
+ /** Run a search for the caller who asks. */
9
+ search: (options: SearchOptions, base: string, req?: Request) => Promise<SearchResult | AsyncGenerator<SearchResult>>;
10
+ /** Whether any plugin filters search results. */
11
+ hasFilter: () => boolean;
12
+ /** Hand one chunk to `ldapsearchfilter`. */
13
+ filter: (chunk: SearchResult, req?: Request, opts?: SearchOptions) => Promise<SearchResult>;
14
+ }
15
+ export default class CallerView {
16
+ private readonly search;
17
+ private readonly hasFilter;
18
+ private readonly filterHook;
19
+ constructor(options: CallerViewOptions);
20
+ /**
21
+ * Whether the caller of a request can read an entry.
22
+ *
23
+ * For a route that finds an entry as the server — by an attribute, across
24
+ * branches — and then answers about it: an entry the caller cannot read
25
+ * must get the answer an entry that is not there gets, or the route tells
26
+ * them what the directory holds where they cannot read. Refused or
27
+ * filtered out by an authorization plugin, the entry reads as absent.
28
+ * Without a request, as for any operation, nobody judges.
29
+ *
30
+ * @param dn the entry
31
+ * @param req the request whose caller asks
32
+ * @returns whether a read of the entry, as that caller, finds it
33
+ */
34
+ readableBy(dn: string, req?: Request): Promise<boolean>;
35
+ /**
36
+ * Hand a search result to `ldapsearchfilter`, which may drop entries the
37
+ * caller is not allowed to see.
38
+ *
39
+ * Applied on every return path of `search`, and deliberately after the
40
+ * cache: `ldapsearchresult` fires before caching and knows no request, so
41
+ * what it removed for one caller would be served to the next. A paginated
42
+ * search is filtered chunk by chunk, as it is consumed.
43
+ */
44
+ filter(value: SearchResult | AsyncGenerator<SearchResult>, req?: Request, opts?: SearchOptions): SearchResult | AsyncGenerator<SearchResult> | Promise<SearchResult>;
45
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * The directory connections this server keeps: failover between the URLs,
3
+ * a bounded pool with a TTL, and the readiness probe on a connection of its
4
+ * own.
5
+ */
6
+ import { Client } from 'ldapts';
7
+ import type { ClientOptions } from 'ldapts';
8
+ export interface PoolLogger {
9
+ debug(message: string): void;
10
+ info(message: string): void;
11
+ warn(message: string): void;
12
+ error(message: string): void;
13
+ }
14
+ export interface PooledConnection {
15
+ client: Client;
16
+ createdAt: number;
17
+ inUse: boolean;
18
+ }
19
+ /** Opens a client; binding is the pool's, so a fake can stand in. */
20
+ export type ClientFactory = (options: ClientOptions) => Client | Promise<Client>;
21
+ export interface LdapPoolOptions {
22
+ /** The URLs to try. */
23
+ urls: string[];
24
+ dn: string;
25
+ pwd: string;
26
+ /** Options shared by every URL; the pool sets `url` and `tlsOptions`. */
27
+ options: ClientOptions;
28
+ poolSize: number;
29
+ connectionTtl: number;
30
+ logger: PoolLogger;
31
+ clientFactory?: ClientFactory;
32
+ }
33
+ export default class LdapPool {
34
+ private readonly urls;
35
+ private readonly dn;
36
+ private readonly pwd;
37
+ private readonly options;
38
+ private readonly poolSize;
39
+ private readonly connectionTtl;
40
+ private readonly logger;
41
+ private readonly clientFactory;
42
+ private connectionPool;
43
+ private availableConnections;
44
+ private waitingResolvers;
45
+ /** Connections being opened, not yet in `connectionPool` */
46
+ private opening;
47
+ private isCleaningUp;
48
+ private currentUrlIndex;
49
+ /** Set by `close`: no connection is handed out any more */
50
+ private closed;
51
+ constructor(options: LdapPoolOptions);
52
+ /**
53
+ * Create a new LDAP connection with failover support
54
+ */
55
+ private createConnection;
56
+ /**
57
+ * Clean up expired connections from the pool
58
+ * Uses a flag to prevent concurrent cleanup operations
59
+ */
60
+ private cleanupExpiredConnections;
61
+ /**
62
+ * Ask the directory for its root DSE on a connection of its own, bound
63
+ * as the server and closed after: the readiness probe. Neither the pool
64
+ * nor the search cache nor the hooks answer for a directory that does
65
+ * not, and a directory that stops answering holds nothing of the pool.
66
+ * The URLs are tried as `createConnection` tries them.
67
+ *
68
+ * @param timeoutMs how long the connection, the bind and the search may
69
+ * each take; the client closes the socket when it passes
70
+ * @throws when no URL answers in time
71
+ */
72
+ ping(timeoutMs: number): Promise<void>;
73
+ /**
74
+ * Close every pooled connection, the ones in use included: an operation
75
+ * still running on one is rejected by the client, and a waiter is refused
76
+ * rather than kept.
77
+ */
78
+ close(): Promise<void>;
79
+ /**
80
+ * Acquire a connection from the pool or create a new one
81
+ * Optimized for O(1) lookup using separate available connections queue
82
+ */
83
+ acquire(): Promise<PooledConnection>;
84
+ /**
85
+ * Run `operation` on a pooled connection, released whatever becomes of it.
86
+ */
87
+ withConnection<T>(operation: (client: Client) => Promise<T>): Promise<T>;
88
+ /** Throw when `close` ran: the pool hands out no connection any more. */
89
+ assertOpen(): void;
90
+ /** The pool as it is, for the metrics */
91
+ stats(): {
92
+ busy: number;
93
+ idle: number;
94
+ waiting: number;
95
+ opening: number;
96
+ max: number;
97
+ };
98
+ private takeConnection;
99
+ /** Refuse every queued request with `error` */
100
+ private refuseWaiters;
101
+ /** Let one queued request take a place that freed with no connection */
102
+ private wakeWaiter;
103
+ /**
104
+ * Release a connection back to the pool
105
+ * If there are waiting requests, hand off the connection directly
106
+ */
107
+ release(pooled: PooledConnection): void;
108
+ }