ldap-rest 1.1.0 → 1.2.0

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 (97) hide show
  1. package/bin/index.mjs +8 -7
  2. package/dist/abstract/ldapFlat.js +1 -1
  3. package/dist/abstract/plugin.js +1 -1
  4. package/dist/abstract/twakePlugin.js +1 -1
  5. package/dist/bin/index.js +1 -1
  6. package/dist/config/args.js +1 -1
  7. package/dist/config/schema.js +1 -1
  8. package/dist/lib/changeContext.js +1 -1
  9. package/dist/lib/entitySchema/validator.js +1 -0
  10. package/dist/lib/expressFormatedResponses.js +1 -1
  11. package/dist/lib/health.js +1 -0
  12. package/dist/lib/instanceProviders.js +1 -1
  13. package/dist/lib/ldapActions.js +1 -1
  14. package/dist/lib/metrics.js +1 -0
  15. package/dist/lib/requestContext.js +1 -1
  16. package/dist/lib/shutdown.js +1 -0
  17. package/dist/lib/staticFiles.js +1 -0
  18. package/dist/lib/storage/store.js +1 -1
  19. package/dist/lib/utils.js +1 -1
  20. package/dist/logger/winston.js +1 -1
  21. package/dist/plugins/auth/crowdsec.js +1 -1
  22. package/dist/plugins/auth/openidconnect.js +1 -1
  23. package/dist/plugins/demo/helloworld.js +1 -1
  24. package/dist/plugins/ldap/bulkImport.js +1 -1
  25. package/dist/plugins/ldap/flatGeneric.js +1 -1
  26. package/dist/plugins/ldap/groups.js +1 -1
  27. package/dist/plugins/ldap/organizations.js +1 -1
  28. package/dist/plugins/metrics.js +1 -0
  29. package/dist/plugins/rabbitmq.js +1 -1
  30. package/dist/plugins/scim/scim.js +1 -1
  31. package/dist/plugins/static.js +1 -1
  32. package/dist/plugins/storage/file.js +1 -1
  33. package/dist/plugins/storage/postgres.js +1 -1
  34. package/dist/plugins/storage/valkey.js +1 -1
  35. package/dist/plugins/storage.js +1 -1
  36. package/dist/plugins/twake/calendar.js +1 -1
  37. package/dist/plugins/twake/clouderyProvision.js +1 -1
  38. package/dist/plugins/twake/cozyProvision.js +1 -1
  39. package/dist/plugins/twake/drive.js +1 -1
  40. package/dist/plugins/twake/james.js +1 -1
  41. package/dist/plugins/twake/migrationProxy.js +1 -0
  42. package/dist/plugins/weblogs.js +1 -1
  43. package/dist/src/abstract/ldapFlat.d.ts +3 -13
  44. package/dist/src/abstract/plugin.d.ts +20 -0
  45. package/dist/src/abstract/twakePlugin.d.ts +23 -1
  46. package/dist/src/bin/index.d.ts +46 -1
  47. package/dist/src/config/args.d.ts +12 -1
  48. package/dist/src/lib/changeContext.d.ts +11 -0
  49. package/dist/src/lib/entitySchema/validator.d.ts +64 -0
  50. package/dist/src/lib/expressFormatedResponses.d.ts +19 -0
  51. package/dist/src/lib/health.d.ts +63 -0
  52. package/dist/src/lib/instanceProviders.d.ts +4 -0
  53. package/dist/src/lib/ldapActions.d.ts +28 -0
  54. package/dist/src/lib/metrics.d.ts +44 -0
  55. package/dist/src/lib/requestContext.d.ts +40 -0
  56. package/dist/src/lib/shutdown.d.ts +20 -0
  57. package/dist/src/lib/staticFiles.d.ts +33 -0
  58. package/dist/src/lib/storage/store.d.ts +8 -0
  59. package/dist/src/lib/utils.d.ts +9 -0
  60. package/dist/src/logger/winston.d.ts +4 -0
  61. package/dist/src/plugins/ldap/groups.d.ts +18 -3
  62. package/dist/src/plugins/ldap/organizations.d.ts +3 -5
  63. package/dist/src/plugins/metrics.d.ts +17 -0
  64. package/dist/src/plugins/rabbitmq.d.ts +37 -1
  65. package/dist/src/plugins/static.d.ts +12 -0
  66. package/dist/src/plugins/storage/file.d.ts +4 -0
  67. package/dist/src/plugins/storage/postgres.d.ts +7 -14
  68. package/dist/src/plugins/storage/valkey.d.ts +2 -15
  69. package/dist/src/plugins/storage.d.ts +8 -0
  70. package/dist/src/plugins/twake/drive.d.ts +8 -0
  71. package/dist/src/plugins/twake/james.d.ts +2 -0
  72. package/dist/src/plugins/twake/migrationProxy.d.ts +71 -0
  73. package/docs/client-development/api/reference.md +5 -9
  74. package/docs/client-development/api/rest-api.md +4 -10
  75. package/docs/client-development/schemas/README.md +60 -21
  76. package/docs/plugin-development/README.md +15 -13
  77. package/docs/plugin-development/dependencies.md +8 -0
  78. package/docs/plugin-development/hooks.md +1 -1
  79. package/docs/usage/README.md +1 -0
  80. package/docs/usage/configuration.md +49 -11
  81. package/docs/usage/operations.md +193 -0
  82. package/docs/usage/plugins/README.md +2 -0
  83. package/docs/usage/plugins/auth/authz-per-branch.md +2 -1
  84. package/docs/usage/plugins/integrations/README.md +8 -4
  85. package/docs/usage/plugins/integrations/mail-migration.md +102 -0
  86. package/docs/usage/plugins/integrations/scim.md +4 -2
  87. package/docs/usage/plugins/ldap/bulk-import.md +3 -1
  88. package/docs/usage/plugins/ldap/flat-generic.md +5 -2
  89. package/docs/usage/plugins/ldap/on-change.md +2 -1
  90. package/docs/usage/plugins/ldap/organizations.md +7 -7
  91. package/docs/usage/plugins/utilities/metrics.md +21 -0
  92. package/docs/usage/plugins/utilities/static.md +16 -8
  93. package/docs/usage/plugins/utilities/storage.md +7 -0
  94. package/docs/usage/plugins/utilities/weblogs.md +51 -30
  95. package/docs/usage/upgrading.md +71 -0
  96. package/package.json +11 -2
  97. package/static/schemas/example/README.md +1 -0
@@ -1 +1 @@
1
- import e from"node-fetch";import{TwakePlugin as t}from"../../abstract/twakePlugin.js";import{isDnInBranch as r,rdnValue as s}from"../../lib/utils.js";function isRegisteredUser(e){return"object"==typeof e&&null!==e&&"string"==typeof e.id}class Calendar extends t{constructor(e){super(e,"calendar_webadmin_url","calendar_webadmin_token","calendar_concurrency"),this.name="calendar",this.roles=["consistency"],this.dependencies={onLdapChange:"core/ldap/onChange"},this.hooks={ldapcalendarResourceadddone:async e=>{const[t,r]=e;if(!this.isResource(t,r))return;const s=this.buildResourceData(t,r);s?await this.callWebAdminApi("ldapcalendarResourceadddone",`${this.webadminUrl}/resources`,"POST",t,JSON.stringify(s),{resourceName:s.name}):this.logger.debug(`Skipping resource creation for ${t}: missing required fields`)},ldapcalendarResourcemodifydone:async e=>{const[t,r]=e;if(!this.isInResourceBranch(t))return;const s=this.getResourceId(t);if(!s)return;const i={};r.replace?.cn&&(i.name=Array.isArray(r.replace.cn)?String(r.replace.cn[0]):String(r.replace.cn)),r.replace?.description&&(i.description=Array.isArray(r.replace.description)?String(r.replace.description[0]):String(r.replace.description)),0!==Object.keys(i).length?await this.callWebAdminApi("ldapcalendarResourcemodifydone",this.resourceUrl(s),"PATCH",t,JSON.stringify(i),{resourceId:s,...i}):this.logger.debug(`No relevant changes for resource ${t}`)},ldapcalendarResourcedeletedone:async e=>{if(!this.isInResourceBranch(e))return;const t=this.getResourceId(e);t&&await this.callWebAdminApi("ldapcalendarResourcedeletedone",this.resourceUrl(t),"DELETE",e,null,{resourceId:t})},onLdapChange:async(e,t)=>{const r=t[this.mailAttr];if(r){const t=this.attributeToString(r[0]),s=this.attributeToString(r[1]);return void(t&&s&&t!==s&&await this.syncRegisteredUser("onLdapMailChange",e,t))}(t[this.firstnameAttr]||t[this.lastnameAttr])&&await this.syncRegisteredUser("onLdapDisplayNameChange",e)}},this.resourceBase=this.config.calendar_resource_base||"",this.resourceObjectClass=this.config.calendar_resource_objectclass||"",this.resourceCreator=this.config.calendar_resource_creator||"admin@example.com",this.resourceDomain=this.config.calendar_resource_domain||"";const t=this.config.ldap_base;this.resourceBase&&t&&!r(this.resourceBase,t)&&this.logger.warn(`Calendar plugin: calendar_resource_base (${this.resourceBase}) is not a DN under ${t}; no entry will be taken for a resource`),this.firstnameAttr=this.config.calendar_firstname_attribute||"givenName",this.lastnameAttr=this.config.calendar_lastname_attribute||"sn"}isResource(e,t){if(!this.isInResourceBranch(e))return!1;const r=t.objectClass;if(this.resourceObjectClass){return(Array.isArray(r)?r:[r]).some(e=>String(e).toLowerCase()===this.resourceObjectClass.toLowerCase())}return!0}isInResourceBranch(e){return!this.resourceBase||r(e,this.resourceBase)}resourceUrl(e){return`${this.webadminUrl}/resources/${encodeURIComponent(e)}`}buildResourceData(e,t){const r=t.cn,s=this.attributeToString(r);if(!s)return null;const i=this.attributeToString(t.description)||void 0,a=this.resourceCreator,n=this.resourceDomain||this.extractDomainFromDn(e),o=this.getResourceId(e);return o?{name:s,description:i,creator:a,domain:n,id:o}:null}getResourceId(e){return s(e)||null}async findRegisteredUser(t,r){const s={...r,step:"find_registered_user",searchEmail:t},i=new URL(`${this.webadminUrl}/registeredUsers`);i.searchParams.set("email",t);const a=await this.requestLimit(()=>e(i.toString(),{method:"GET",headers:this.createHeaders()}));if(404!==a.status){if(!a.ok)return this.logger.error({...s,http_status:a.status,http_status_text:a.statusText}),null;let e;try{e=await a.json()}catch(e){return this.logger.error({...s,http_status:a.status,error:`unreadable answer: ${e}`}),null}if(!Array.isArray(e))return isRegisteredUser(e)?e:(this.logger.error({...s,http_status:a.status,error:"answer is not a registered user"}),null);const r=e.find(e=>isRegisteredUser(e)&&e.email?.toLowerCase()===t.toLowerCase());if(r)return r}return this.logger.warn({...s,message:"user not registered in Calendar"}),null}async syncRegisteredUser(t,r,s){const i={plugin:this.name,event:t,result:"error",dn:r},a=await this.ldapGetAttributes(r,[this.mailAttr,this.firstnameAttr,this.lastnameAttr]);if(!a)return void this.logger.warn({...i,message:`Cannot sync registered user: entry not found for ${r}`});const n=this.attributeToString(a[this.mailAttr]);if(!n)return void this.logger.warn({...i,message:`Cannot sync registered user: no mail found for ${r}`});const o=this.attributeToString(a[this.firstnameAttr]),c=this.attributeToString(a[this.lastnameAttr]),u=s||n;try{const t=await this.findRegisteredUser(u,i);if(!t)return;const r=new URL(`${this.webadminUrl}/registeredUsers`);r.searchParams.set("id",t.id);const s={email:n};o&&(s.firstname=o),c&&(s.lastname=c);const a=await this.requestLimit(()=>e(r.toString(),{method:"PATCH",headers:this.createHeaders("application/json"),body:JSON.stringify(s)}));a.ok?this.logger.info({...i,result:"success",id:t.id,http_status:a.status,...s}):this.logger.error({...i,step:"patch_registered_user",id:t.id,http_status:a.status,http_status_text:a.statusText})}catch(e){this.logger.error({...i,error:`${e}`})}}async deleteUserData(t){const r={plugin:this.name,event:"deleteUserData",mail:t};try{const s=new URL(`${this.webadminUrl}/users/${t}`);s.searchParams.set("action","deleteData");const i=await this.requestLimit(()=>e(s.toString(),{method:"POST",headers:this.createHeaders()}));if(!i.ok)return this.logger.error({...r,http_status:i.status,http_status_text:i.statusText}),null;const a=await i.json();return this.logger.info({...r,http_status:i.status,taskId:a.taskId}),a}catch(e){return this.logger.error({...r,error:`${e}`}),null}}}export{Calendar as default};
1
+ import{TwakePlugin as e}from"../../abstract/twakePlugin.js";import{isDnInBranch as t,rdnValue as r}from"../../lib/utils.js";function isRegisteredUser(e){return"object"==typeof e&&null!==e&&"string"==typeof e.id}class Calendar extends e{constructor(e){super(e,"calendar_webadmin_url","calendar_webadmin_token","calendar_concurrency"),this.name="calendar",this.roles=["consistency"],this.dependencies={onLdapChange:"core/ldap/onChange"},this.hooks={ldapcalendarResourceadddone:async e=>{const[t,r]=e;if(!this.isResource(t,r))return;const s=this.buildResourceData(t,r);s?await this.callWebAdminApi("ldapcalendarResourceadddone",`${this.webadminUrl}/resources`,"POST",t,JSON.stringify(s),{resourceName:s.name}):this.logger.debug(`Skipping resource creation for ${t}: missing required fields`)},ldapcalendarResourcemodifydone:async e=>{const[t,r]=e;if(!this.isInResourceBranch(t))return;const s=this.getResourceId(t);if(!s)return;const i={};r.replace?.cn&&(i.name=Array.isArray(r.replace.cn)?String(r.replace.cn[0]):String(r.replace.cn)),r.replace?.description&&(i.description=Array.isArray(r.replace.description)?String(r.replace.description[0]):String(r.replace.description)),0!==Object.keys(i).length?await this.callWebAdminApi("ldapcalendarResourcemodifydone",this.resourceUrl(s),"PATCH",t,JSON.stringify(i),{resourceId:s,...i}):this.logger.debug(`No relevant changes for resource ${t}`)},ldapcalendarResourcedeletedone:async e=>{if(!this.isInResourceBranch(e))return;const t=this.getResourceId(e);t&&await this.callWebAdminApi("ldapcalendarResourcedeletedone",this.resourceUrl(t),"DELETE",e,null,{resourceId:t})},onLdapChange:async(e,t)=>{const r=t[this.mailAttr];if(r){const t=this.attributeToString(r[0]),s=this.attributeToString(r[1]);return void(t&&s&&t!==s&&await this.syncRegisteredUser("onLdapMailChange",e,t))}(t[this.firstnameAttr]||t[this.lastnameAttr])&&await this.syncRegisteredUser("onLdapDisplayNameChange",e)}},this.resourceBase=this.config.calendar_resource_base||"",this.resourceObjectClass=this.config.calendar_resource_objectclass||"",this.resourceCreator=this.config.calendar_resource_creator||"admin@example.com",this.resourceDomain=this.config.calendar_resource_domain||"";const r=this.config.ldap_base;this.resourceBase&&r&&!t(this.resourceBase,r)&&this.logger.warn(`Calendar plugin: calendar_resource_base (${this.resourceBase}) is not a DN under ${r}; no entry will be taken for a resource`),this.firstnameAttr=this.config.calendar_firstname_attribute||"givenName",this.lastnameAttr=this.config.calendar_lastname_attribute||"sn"}isResource(e,t){if(!this.isInResourceBranch(e))return!1;const r=t.objectClass;if(this.resourceObjectClass){return(Array.isArray(r)?r:[r]).some(e=>String(e).toLowerCase()===this.resourceObjectClass.toLowerCase())}return!0}isInResourceBranch(e){return!this.resourceBase||t(e,this.resourceBase)}resourceUrl(e){return`${this.webadminUrl}/resources/${encodeURIComponent(e)}`}buildResourceData(e,t){const r=t.cn,s=this.attributeToString(r);if(!s)return null;const i=this.attributeToString(t.description)||void 0,a=this.resourceCreator,n=this.resourceDomain||this.extractDomainFromDn(e),o=this.getResourceId(e);return o?{name:s,description:i,creator:a,domain:n,id:o}:null}getResourceId(e){return r(e)||null}async findRegisteredUser(e,t){const r={...t,step:"find_registered_user",searchEmail:e},s=new URL(`${this.webadminUrl}/registeredUsers`);s.searchParams.set("email",e);const i=await this.requestLimit(()=>this.fetch(s.toString(),{method:"GET",headers:this.createHeaders()}));if(404!==i.status){if(!i.ok)return this.logger.error({...r,http_status:i.status,http_status_text:i.statusText}),null;let t;try{t=await i.json()}catch(e){return this.logger.error({...r,http_status:i.status,error:`unreadable answer: ${e}`}),null}if(!Array.isArray(t))return isRegisteredUser(t)?t:(this.logger.error({...r,http_status:i.status,error:"answer is not a registered user"}),null);const s=t.find(t=>isRegisteredUser(t)&&t.email?.toLowerCase()===e.toLowerCase());if(s)return s}return this.logger.warn({...r,message:"user not registered in Calendar"}),null}async syncRegisteredUser(e,t,r){const s={plugin:this.name,event:e,result:"error",dn:t},i=await this.ldapGetAttributes(t,[this.mailAttr,this.firstnameAttr,this.lastnameAttr]);if(!i)return void this.logger.warn({...s,message:`Cannot sync registered user: entry not found for ${t}`});const a=this.attributeToString(i[this.mailAttr]);if(!a)return void this.logger.warn({...s,message:`Cannot sync registered user: no mail found for ${t}`});const n=this.attributeToString(i[this.firstnameAttr]),o=this.attributeToString(i[this.lastnameAttr]),c=r||a;try{const e=await this.findRegisteredUser(c,s);if(!e)return;const t=new URL(`${this.webadminUrl}/registeredUsers`);t.searchParams.set("id",e.id);const r={email:a};n&&(r.firstname=n),o&&(r.lastname=o);const i=await this.requestLimit(()=>this.fetch(t.toString(),{method:"PATCH",headers:this.createHeaders("application/json"),body:JSON.stringify(r)}));i.ok?this.logger.info({...s,result:"success",id:e.id,http_status:i.status,...r}):this.logger.error({...s,step:"patch_registered_user",id:e.id,http_status:i.status,http_status_text:i.statusText})}catch(e){this.logger.error({...s,error:`${e}`})}}async deleteUserData(e){const t={plugin:this.name,event:"deleteUserData",mail:e};try{const r=new URL(`${this.webadminUrl}/users/${e}`);r.searchParams.set("action","deleteData");const s=await this.requestLimit(()=>this.fetch(r.toString(),{method:"POST",headers:this.createHeaders()}));if(!s.ok)return this.logger.error({...t,http_status:s.status,http_status_text:s.statusText}),null;const i=await s.json();return this.logger.info({...t,http_status:s.status,taskId:i.taskId}),i}catch(e){return this.logger.error({...t,error:`${e}`}),null}}}export{Calendar as default};
@@ -1 +1 @@
1
- import{setTimeout as e}from"node:timers/promises";import t from"node-fetch";import r from"../../abstract/plugin.js";import{instanceSlug as i}from"../../lib/instanceProviders.js";import{escapeDnValue as s,isChildOf as n}from"../../lib/utils.js";import{BaseResolver as a}from"../scim/baseResolver.js";const o=new Set(["admin","owner","member"]),l=1e3;class ClouderyProvision extends r{constructor(e){super(e),this.name="clouderyProvision",this.roles=["consistency"],this.dependencies={rabbitmq:"core/rabbitmq"},this.createStash=new Map,this.deleteStash=new Map,this.hooks={scimusercreate:e=>(this.normalizeUserName(e[0]),this.captureCreate(e[0],e[1]),e),scimusercreatedone:async e=>{await this.provision(e)},scimuserdelete:async e=>(e[0]=e[0].toLowerCase(),await this.captureDelete(e[0],e[1]),e),scimuserdeletedone:async e=>{await this.deprovision(e)}};const t=this.config;this.managerUrl=(t.cloudery_manager_url||"").replace(/\/$/,""),this.managerToken=t.cloudery_manager_token||"",this.offer=t.cloudery_offer||"b2b_twake_default",this.clouderyDomain=t.cloudery_domain||"",this.userBranch=t.cloudery_user_branch||"",this.orgIdHeader=(t.cloudery_org_id_header||"x-cloudery-org-id").toLowerCase(),this.orgRoleHeader=(t.cloudery_org_role_header||"x-cloudery-org-role").toLowerCase();const r=t.cloudery_default_org_role||"member",i=r.trim().toLowerCase();o.has(i)?this.defaultOrgRole=i:(this.defaultOrgRole="member",this.logger.warn(`${this.name}: cloudery_default_org_role "${r}" is not one of ${[...o].join(", ")} — using "member"`)),this.fqdnAttribute=t.cloudery_fqdn_attribute||"twakeWorkspaceUrl",this.orgIdAttribute=t.cloudery_org_id_attribute||"twakeOrganizationId",this.orgRoleAttribute=t.cloudery_org_role_attribute||"twakeOrganizationRole",this.phonesAttribute=t.cloudery_phones_attribute||"twakePhones",this.invitedAttribute=t.cloudery_invited_attribute||"twakeInvited",this.defaultLocale=t.cloudery_default_locale||"en",this.rdnAttribute=t.scim_user_rdn_attribute||"uid",this.authExchange=t.cozy_auth_exchange||"auth",this.b2bExchange=t.cozy_b2b_exchange||"b2b",this.userCreatedRoutingKey=t.cozy_user_created_routing_key||"user.created",this.userDeletedRoutingKey=t.cozy_user_deleted_routing_key??"domain.user.deleted",this.pollIntervalMs=Number(t.cloudery_workflow_poll_interval_ms)||2e3,this.maxPollAttempts=Number(t.cloudery_workflow_max_attempts)||60,this.baseResolver=new a(this.config),this.managerUrl||this.logger.warn(`${this.name}: cloudery_manager_url is empty — instance provisioning will be skipped`),this.userBranch||this.logger.warn(`${this.name}: cloudery_user_branch is empty — no users will be provisioned`),this.logger.info({plugin:this.name,event:"init",managerUrl:this.managerUrl||void 0,userBranch:this.userBranch||void 0,orgIdHeader:this.orgIdHeader,orgRoleHeader:this.orgRoleHeader,defaultOrgRole:this.defaultOrgRole,fqdnAttribute:this.fqdnAttribute,orgIdAttribute:this.orgIdAttribute,orgRoleAttribute:this.orgRoleAttribute,phonesAttribute:this.phonesAttribute,invitedAttribute:this.invitedAttribute,pollIntervalMs:this.pollIntervalMs,maxPollAttempts:this.maxPollAttempts})}captureCreate(e,t){this.pruneStash();const r=this.extractId(e),i=this.baseResolver.userBase(t);if(this.logger.info({plugin:this.name,event:"captureCreate",userName:r||void 0,base:i,hasManagerUrl:Boolean(this.managerUrl),hasUserBranch:Boolean(this.userBranch)}),!this.managerUrl||!this.userBranch)return void this.logger.info({plugin:this.name,event:"captureCreate",userName:r||void 0,base:i,result:"skipped",reason:"missing_configuration"});if(!r)return void this.logger.info({plugin:this.name,event:"captureCreate",base:i,result:"skipped",reason:"missing_user_name"});if(!this.isAtOrUnder(i,this.userBranch))return void this.logger.info({plugin:this.name,event:"captureCreate",userName:r,base:i,result:"skipped",reason:"outside_b2b_branch"});const s=this.readHeader(t,this.orgIdHeader);if(!s)return void this.logger.warn({plugin:this.name,event:"captureCreate",userName:r,message:`B2B branch create without ${this.orgIdHeader} — skipping provisioning`});const n=this.extractPrimaryEmail(e);if(!n)return void this.logger.warn({plugin:this.name,event:"captureCreate",userName:r,message:"B2B branch create without a primary email — skipping provisioning"});const a=n.trim(),o=this.resolveRole(t,r);this.createStash.set(a.toLowerCase(),{orgId:s.trim(),email:a,role:o,base:i,ts:Date.now()}),this.logger.info({plugin:this.name,event:"captureCreate",userName:r,base:i,orgId:s.trim(),email:a,role:o,result:"stashed"})}async provision(e){const t=this.extractId(e);if(this.logger.info({plugin:this.name,event:"provision",userName:t||void 0}),!t)return void this.logger.info({plugin:this.name,event:"provision",result:"skipped",reason:"missing_user_name"});const r=this.extractPrimaryEmail(e);if(!r)return void this.logger.info({plugin:this.name,event:"provision",userName:t,result:"skipped",reason:"missing_primary_email"});const n=r.trim().toLowerCase(),a=this.createStash.get(n);if(!a)return void this.logger.info({plugin:this.name,event:"provision",userName:t,email:r.trim(),result:"skipped",reason:"no_stashed_context"});this.createStash.delete(n),this.logger.info({plugin:this.name,event:"provision",userName:t,email:a.email,orgId:a.orgId,base:a.base,role:a.role,result:"context_loaded"});const o=a.email,l=emailDomain(o),u=i(t+a.orgId);if(!u)return void this.logger.error({plugin:this.name,event:"provision",userName:t,result:"no_slug"});const h=this.extractPrimaryPhone(e),g=this.extractPhones(e),d=this.extractPublicName(e)||t,c={plugin:this.name,event:"provision",userName:t,orgId:a.orgId,slug:u},m=`${u}.${this.clouderyDomain}`.toLowerCase();let p=null;try{p=await this.findInstanceByFqdn(m)}catch(e){this.logger.warn({...c,result:"existence_lookup_failed",fqdn:m,error:`${e}`})}if(p&&(p.internal_email&&p.internal_email.toLowerCase()!==o.toLowerCase()||p.oidc&&p.oidc.toLowerCase()!==u))return void this.logger.error({...c,result:"slug_taken",fqdn:m,uuid:p._id});const f=p?._id??null;let w;if(f)this.logger.info({...c,result:"instance_exists",fqdn:m,uuid:f}),w=m;else{let t;try{this.logger.info({...c,result:"creating_instance",orgDomain:l,email:o,mobile:h||void 0}),t=await this.createInstance({email:o,publicName:d,locale:this.extractLocale(e),oidc:u,phone:h||"",slug:u,public_name:d,offer:this.offer,domain:this.clouderyDomain,skip_email_validation:!0,internal_email:o,org_domain:l,org_id:a.orgId})}catch(e){return void this.logger.error({...c,result:"error",error:`${e}`})}if(!(!t.workflow||await this.waitForWorkflow(t.workflow)))return void this.logger.error({...c,result:"workflow_failed",workflow:t.workflow});w=t.fqdn}const v=`${this.rdnAttribute}=${s(t)},${a.base}`,b={[this.fqdnAttribute]:w,[this.orgIdAttribute]:a.orgId,[this.orgRoleAttribute]:a.role,[this.invitedAttribute]:"TRUE",cn:t};g.length>0&&(b[this.phonesAttribute]=JSON.stringify(g));try{this.logger.info({...c,result:"writing_fqdn",dn:v,hasPhones:g.length>0}),await this.server.ldap.system.modify(v,{replace:b},void 0,{declaredClasses:this.config.user_class}),this.logger.info({...c,result:"written_fqdn",dn:v})}catch(e){this.logger.error({...c,event:"writeFqdn",dn:v,error:`${e}`})}await this.publishUserCreated({twakeId:t,orgDomain:l,orgId:a.orgId,fqdn:w,email:o,mobile:h}),this.logger.info({...c,result:"success",fqdn:w})}async captureDelete(e,t){const r=this.baseResolver.userBase(t);if(this.logger.info({plugin:this.name,event:"captureDelete",id:e,base:r,hasManagerUrl:Boolean(this.managerUrl),hasUserBranch:Boolean(this.userBranch)}),!this.managerUrl||!this.userBranch)return void this.logger.info({plugin:this.name,event:"captureDelete",id:e,base:r,result:"skipped",reason:"missing_configuration"});if(!this.isAtOrUnder(r,this.userBranch))return void this.logger.info({plugin:this.name,event:"captureDelete",id:e,base:r,result:"skipped",reason:"outside_b2b_branch"});const i=`${this.rdnAttribute}=${s(e)},${r}`;try{this.logger.info({plugin:this.name,event:"captureDelete",id:e,dn:i,result:"searching"});const t=await this.server.ldap.system.search({paged:!1,scope:"base",attributes:[this.fqdnAttribute,"mail"]},i),r=t.searchEntries?.[0],s=r?firstValue(r[this.fqdnAttribute]):void 0,n=s?s.trim().replace(/^[a-z][a-z0-9+.-]*:\/\//i,"").split("/")[0]:void 0;if(!n)return void this.logger.info({plugin:this.name,event:"captureDelete",id:e,dn:i,result:"skipped",reason:"missing_fqdn"});const a=r?firstValue(r.mail):void 0,o=a?emailDomain(a):"";this.deleteStash.set(e,{fqdn:n,domain:o}),this.logger.info({plugin:this.name,event:"captureDelete",id:e,dn:i,fqdn:n,domain:o,result:"stashed"})}catch(t){this.logger.warn({plugin:this.name,event:"captureDelete",id:e,error:`${t}`})}}async deprovision(e){this.logger.info({plugin:this.name,event:"deprovision",id:e});const t=this.deleteStash.get(e);if(!t)return void this.logger.info({plugin:this.name,event:"deprovision",id:e,result:"skipped",reason:"no_stashed_context"});this.deleteStash.delete(e);const r={plugin:this.name,event:"deprovision",id:e,fqdn:t.fqdn};let i=!1;try{this.logger.info({...r,result:"resolving_uuid"});const e=(await this.findInstanceByFqdn(t.fqdn,"_id"))?._id;e?(this.logger.info({...r,result:"deleting_instance",uuid:e}),await this.deleteInstance(e),i=!0,this.logger.info({...r,result:"deleted",uuid:e})):this.logger.warn({...r,result:"not_found"})}catch(e){this.logger.error({...r,result:"error",error:`${e}`})}i&&await this.publishUserDeleted(t.fqdn,t.domain)}async createInstance(e){this.logger.info({plugin:this.name,event:"createInstance",url:`${this.managerUrl}/api/v1/instances`});const r=await t(`${this.managerUrl}/api/v1/instances`,{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${this.managerToken}`},body:JSON.stringify(e)});if(!r.ok){const e=(await r.text().catch(()=>"")).slice(0,l);throw this.logger.error({plugin:this.name,event:"createInstance",result:"error",http_status:r.status,http_status_text:r.statusText,body:e||void 0}),new Error(`Cloudery createInstance HTTP ${r.status}`)}const i=await r.json();return this.logger.info({plugin:this.name,event:"createInstance",result:"success",id:i.id,fqdn:i.fqdn,workflow:i.workflow}),i}async waitForWorkflow(r){this.logger.info({plugin:this.name,event:"waitForWorkflow",workflow:r,maxPollAttempts:this.maxPollAttempts,pollIntervalMs:this.pollIntervalMs});for(let i=1;i<=this.maxPollAttempts;i++){try{const e=await t(`${this.managerUrl}/api/v1/workflows/${encodeURIComponent(r)}`,{headers:{Authorization:`Bearer ${this.managerToken}`}});if(e.ok){const t=await e.json();if(this.logger.info({plugin:this.name,event:"waitForWorkflow",workflow:r,attempt:i,status:t.status}),"succeeded"===t.status)return this.logger.info({plugin:this.name,event:"waitForWorkflow",workflow:r,attempt:i,result:"success"}),!0;if("failed"===t.status||"error"===t.status)return this.logger.warn({plugin:this.name,event:"waitForWorkflow",workflow:r,attempt:i,result:"failed",status:t.status}),!1}else{const t=(await e.text().catch(()=>"")).slice(0,l);this.logger.info({plugin:this.name,event:"waitForWorkflow",workflow:r,attempt:i,result:"http_error",http_status:e.status,http_status_text:e.statusText,body:t||void 0})}}catch(e){this.logger.info({plugin:this.name,event:"waitForWorkflow",workflow:r,attempt:i,error:`${e}`})}i<this.maxPollAttempts&&(this.logger.info({plugin:this.name,event:"waitForWorkflow",workflow:r,attempt:i,result:"retrying"}),await e(this.pollIntervalMs))}return this.logger.info({plugin:this.name,event:"waitForWorkflow",workflow:r,result:"timeout",maxPollAttempts:this.maxPollAttempts}),!1}async findInstanceByFqdn(e,r){this.logger.info({plugin:this.name,event:"findInstanceByFqdn",fqdn:e});const i=new URL(`${this.managerUrl}/api/v2/instances`);i.searchParams.set("fqdn",e),r&&i.searchParams.set("only",r),i.searchParams.set("limit","1");const s=await t(i.toString(),{headers:{Authorization:`Bearer ${this.managerToken}`}});if(!s.ok){const t=(await s.text().catch(()=>"")).slice(0,l);throw this.logger.error({plugin:this.name,event:"findInstanceByFqdn",fqdn:e,result:"error",http_status:s.status,http_status_text:s.statusText,body:t||void 0}),new Error(`Cloudery searchInstances HTTP ${s.status}`)}const n=await s.json(),a=n.items?.[0]??null,o=a?._id;return this.logger.info({plugin:this.name,event:"findInstanceByFqdn",fqdn:e,result:o?"found":"not_found",uuid:o||void 0}),a}async deleteInstance(e){const r=new URL(`${this.managerUrl}/api/v1/instances/${encodeURIComponent(e)}`);r.searchParams.set("user_request","true"),this.logger.info({plugin:this.name,event:"deleteInstance",uuid:e,url:r.toString()});const i=await t(r.toString(),{method:"DELETE",headers:{Authorization:`Bearer ${this.managerToken}`}});if(!i.ok){const t=(await i.text().catch(()=>"")).slice(0,l);throw this.logger.error({plugin:this.name,event:"deleteInstance",uuid:e,result:"error",http_status:i.status,http_status_text:i.statusText,body:t||void 0}),new Error(`Cloudery deleteInstance HTTP ${i.status}`)}this.logger.info({plugin:this.name,event:"deleteInstance",uuid:e,result:"success",http_status:i.status})}async publishUserCreated(e){const t=this.requirePlugin("rabbitmq");if(this.logger.info({plugin:this.name,event:"publishUserCreated",twakeId:e.twakeId,orgId:e.orgId,orgDomain:e.orgDomain,hasRabbitmq:Boolean(t)}),!t)return void this.logger.info({plugin:this.name,event:"publishUserCreated",twakeId:e.twakeId,result:"skipped",reason:"missing_rabbitmq"});const r={twakeId:e.twakeId,domain:e.orgDomain,organizationDomain:e.orgDomain,workplaceFqdn:e.fqdn,organizationId:e.orgId};e.email&&(r.internalEmail=e.email),e.mobile&&(r.mobile=e.mobile);try{await t.publish(this.authExchange,this.userCreatedRoutingKey,r),this.logger.info({plugin:this.name,event:"publishUserCreated",twakeId:e.twakeId,exchange:this.authExchange,routingKey:this.userCreatedRoutingKey,result:"success"})}catch(t){this.logger.error({plugin:this.name,event:"publishUserCreated",twakeId:e.twakeId,error:`${t}`})}}async publishUserDeleted(e,t){if(!this.userDeletedRoutingKey)return;const r=this.requirePlugin("rabbitmq");if(this.logger.info({plugin:this.name,event:"publishUserDeleted",workplaceFqdn:e,domain:t,hasRabbitmq:Boolean(r)}),r)try{await r.publish(this.b2bExchange,this.userDeletedRoutingKey,{workplaceFqdn:e,domain:t}),this.logger.info({plugin:this.name,event:"publishUserDeleted",workplaceFqdn:e,exchange:this.b2bExchange,routingKey:this.userDeletedRoutingKey,result:"success"})}catch(t){this.logger.error({plugin:this.name,event:"publishUserDeleted",workplaceFqdn:e,error:`${t}`})}else this.logger.info({plugin:this.name,event:"publishUserDeleted",workplaceFqdn:e,result:"skipped",reason:"missing_rabbitmq"})}readHeader(e,t){if(!e)return;const r=e.get?e.get(t):void 0;if("string"==typeof r&&r.length>0)return r;const i=e.query?.[t];return"string"==typeof i&&i.length>0?i:void 0}resolveRole(e,t){const r=this.readHeader(e,this.orgRoleHeader)?.trim().toLowerCase();return r?o.has(r)?(this.logger.debug({plugin:this.name,event:"resolveRole",userName:t,input:r,result:"accepted",role:r}),r):(this.logger.warn({plugin:this.name,event:"captureCreate",userName:t,message:`Unknown ${this.orgRoleHeader} "${r}" — falling back to ${this.defaultOrgRole}`}),this.logger.debug({plugin:this.name,event:"resolveRole",userName:t,input:r,result:"default",role:this.defaultOrgRole}),this.defaultOrgRole):(this.logger.debug({plugin:this.name,event:"resolveRole",userName:t,result:"default",role:this.defaultOrgRole}),this.defaultOrgRole)}isAtOrUnder(e,t){return e.trim().toLowerCase()===t.trim().toLowerCase()||n(e,t)}pruneStash(){const e=Date.now()-3e5;for(const[t,r]of this.createStash)r.ts<e&&this.createStash.delete(t)}normalizeUserName(e){"string"==typeof e.userName&&(e.userName=e.userName.toLowerCase())}extractId(e){return"string"==typeof e.userName&&e.userName.length>0?e.userName:"string"==typeof e.id&&e.id.length>0?e.id:null}extractPrimaryEmail(e){const t=e.emails;if(!t||0===t.length)return null;const r=(t.find(e=>e.primary)||t[0]).value;return"string"==typeof r&&r.length>0?r:null}extractPrimaryPhone(e){const t=e.phoneNumbers;if(!t||0===t.length)return null;const r=(t.find(e=>e.primary)||t[0]).value;return"string"==typeof r&&r.length>0?r:null}extractPhones(e){const t=e.phoneNumbers;return t&&0!==t.length?t.filter(e=>"string"==typeof e.value&&e.value.trim().length>0).map(e=>({number:e.value.trim(),primary:Boolean(e.primary)})):[]}extractPublicName(e){if("string"==typeof e.displayName&&e.displayName.trim().length>0)return e.displayName.trim();const t=e.name?.formatted;if("string"==typeof t&&t.trim().length>0)return t.trim();const r=`${e.name?.givenName?.trim()??""} ${e.name?.familyName?.trim()??""}`.trim();return r.length>0?r:null}extractLocale(e){return"string"==typeof e.locale&&e.locale.length>0?e.locale:"string"==typeof e.preferredLanguage&&e.preferredLanguage.length>0?e.preferredLanguage:this.defaultLocale}}function emailDomain(e){const t=e.lastIndexOf("@");return t>=0?e.slice(t+1):""}function firstValue(e){if(Array.isArray(e)){const t=e[0];return"string"==typeof t?t:void 0}return"string"==typeof e?e:void 0}export{ClouderyProvision as default};
1
+ import{setTimeout as e}from"node:timers/promises";import t from"../../abstract/plugin.js";import{instanceSlug as r}from"../../lib/instanceProviders.js";import{escapeDnValue as i,isChildOf as s}from"../../lib/utils.js";import{BaseResolver as n}from"../scim/baseResolver.js";const a=new Set(["admin","owner","member"]),o=1e3;class ClouderyProvision extends t{constructor(e){super(e),this.name="clouderyProvision",this.roles=["consistency"],this.dependencies={rabbitmq:"core/rabbitmq"},this.createStash=new Map,this.deleteStash=new Map,this.hooks={scimusercreate:e=>(this.normalizeUserName(e[0]),this.captureCreate(e[0],e[1]),e),scimusercreatedone:async e=>{await this.provision(e)},scimuserdelete:async e=>(e[0]=e[0].toLowerCase(),await this.captureDelete(e[0],e[1]),e),scimuserdeletedone:async e=>{await this.deprovision(e)}};const t=this.config;this.managerUrl=(t.cloudery_manager_url||"").replace(/\/$/,""),this.managerToken=t.cloudery_manager_token||"",this.offer=t.cloudery_offer||"b2b_twake_default",this.clouderyDomain=t.cloudery_domain||"",this.userBranch=t.cloudery_user_branch||"",this.orgIdHeader=(t.cloudery_org_id_header||"x-cloudery-org-id").toLowerCase(),this.orgRoleHeader=(t.cloudery_org_role_header||"x-cloudery-org-role").toLowerCase();const r=t.cloudery_default_org_role||"member",i=r.trim().toLowerCase();a.has(i)?this.defaultOrgRole=i:(this.defaultOrgRole="member",this.logger.warn(`${this.name}: cloudery_default_org_role "${r}" is not one of ${[...a].join(", ")} — using "member"`)),this.fqdnAttribute=t.cloudery_fqdn_attribute||"twakeWorkspaceUrl",this.orgIdAttribute=t.cloudery_org_id_attribute||"twakeOrganizationId",this.orgRoleAttribute=t.cloudery_org_role_attribute||"twakeOrganizationRole",this.phonesAttribute=t.cloudery_phones_attribute||"twakePhones",this.invitedAttribute=t.cloudery_invited_attribute||"twakeInvited",this.defaultLocale=t.cloudery_default_locale||"en",this.rdnAttribute=t.scim_user_rdn_attribute||"uid",this.authExchange=t.cozy_auth_exchange||"auth",this.b2bExchange=t.cozy_b2b_exchange||"b2b",this.userCreatedRoutingKey=t.cozy_user_created_routing_key||"user.created",this.userDeletedRoutingKey=t.cozy_user_deleted_routing_key??"domain.user.deleted",this.pollIntervalMs=Number(t.cloudery_workflow_poll_interval_ms)||2e3,this.maxPollAttempts=Number(t.cloudery_workflow_max_attempts)||60,this.baseResolver=new n(this.config),this.managerUrl||this.logger.warn(`${this.name}: cloudery_manager_url is empty — instance provisioning will be skipped`),this.userBranch||this.logger.warn(`${this.name}: cloudery_user_branch is empty — no users will be provisioned`),this.logger.info({plugin:this.name,event:"init",managerUrl:this.managerUrl||void 0,userBranch:this.userBranch||void 0,orgIdHeader:this.orgIdHeader,orgRoleHeader:this.orgRoleHeader,defaultOrgRole:this.defaultOrgRole,fqdnAttribute:this.fqdnAttribute,orgIdAttribute:this.orgIdAttribute,orgRoleAttribute:this.orgRoleAttribute,phonesAttribute:this.phonesAttribute,invitedAttribute:this.invitedAttribute,pollIntervalMs:this.pollIntervalMs,maxPollAttempts:this.maxPollAttempts})}captureCreate(e,t){this.pruneStash();const r=this.extractId(e),i=this.baseResolver.userBase(t);if(this.logger.info({plugin:this.name,event:"captureCreate",userName:r||void 0,base:i,hasManagerUrl:Boolean(this.managerUrl),hasUserBranch:Boolean(this.userBranch)}),!this.managerUrl||!this.userBranch)return void this.logger.info({plugin:this.name,event:"captureCreate",userName:r||void 0,base:i,result:"skipped",reason:"missing_configuration"});if(!r)return void this.logger.info({plugin:this.name,event:"captureCreate",base:i,result:"skipped",reason:"missing_user_name"});if(!this.isAtOrUnder(i,this.userBranch))return void this.logger.info({plugin:this.name,event:"captureCreate",userName:r,base:i,result:"skipped",reason:"outside_b2b_branch"});const s=this.readHeader(t,this.orgIdHeader);if(!s)return void this.logger.warn({plugin:this.name,event:"captureCreate",userName:r,message:`B2B branch create without ${this.orgIdHeader} — skipping provisioning`});const n=this.extractPrimaryEmail(e);if(!n)return void this.logger.warn({plugin:this.name,event:"captureCreate",userName:r,message:"B2B branch create without a primary email — skipping provisioning"});const a=n.trim(),o=this.resolveRole(t,r);this.createStash.set(a.toLowerCase(),{orgId:s.trim(),email:a,role:o,base:i,ts:Date.now()}),this.logger.info({plugin:this.name,event:"captureCreate",userName:r,base:i,orgId:s.trim(),email:a,role:o,result:"stashed"})}async provision(e){const t=this.extractId(e);if(this.logger.info({plugin:this.name,event:"provision",userName:t||void 0}),!t)return void this.logger.info({plugin:this.name,event:"provision",result:"skipped",reason:"missing_user_name"});const s=this.extractPrimaryEmail(e);if(!s)return void this.logger.info({plugin:this.name,event:"provision",userName:t,result:"skipped",reason:"missing_primary_email"});const n=s.trim().toLowerCase(),a=this.createStash.get(n);if(!a)return void this.logger.info({plugin:this.name,event:"provision",userName:t,email:s.trim(),result:"skipped",reason:"no_stashed_context"});this.createStash.delete(n),this.logger.info({plugin:this.name,event:"provision",userName:t,email:a.email,orgId:a.orgId,base:a.base,role:a.role,result:"context_loaded"});const o=a.email,l=emailDomain(o),u=r(t+a.orgId);if(!u)return void this.logger.error({plugin:this.name,event:"provision",userName:t,result:"no_slug"});const h=this.extractPrimaryPhone(e),g=this.extractPhones(e),d=this.extractPublicName(e)||t,c={plugin:this.name,event:"provision",userName:t,orgId:a.orgId,slug:u},m=`${u}.${this.clouderyDomain}`.toLowerCase();let p=null;try{p=await this.findInstanceByFqdn(m)}catch(e){this.logger.warn({...c,result:"existence_lookup_failed",fqdn:m,error:`${e}`})}if(p&&(p.internal_email&&p.internal_email.toLowerCase()!==o.toLowerCase()||p.oidc&&p.oidc.toLowerCase()!==u))return void this.logger.error({...c,result:"slug_taken",fqdn:m,uuid:p._id});const f=p?._id??null;let w;if(f)this.logger.info({...c,result:"instance_exists",fqdn:m,uuid:f}),w=m;else{let t;try{this.logger.info({...c,result:"creating_instance",orgDomain:l,email:o,mobile:h||void 0}),t=await this.createInstance({email:o,publicName:d,locale:this.extractLocale(e),oidc:u,phone:h||"",slug:u,public_name:d,offer:this.offer,domain:this.clouderyDomain,skip_email_validation:!0,internal_email:o,org_domain:l,org_id:a.orgId})}catch(e){return void this.logger.error({...c,result:"error",error:`${e}`})}if(!(!t.workflow||await this.waitForWorkflow(t.workflow)))return void this.logger.error({...c,result:"workflow_failed",workflow:t.workflow});w=t.fqdn}const v=`${this.rdnAttribute}=${i(t)},${a.base}`,b={[this.fqdnAttribute]:w,[this.orgIdAttribute]:a.orgId,[this.orgRoleAttribute]:a.role,[this.invitedAttribute]:"TRUE",cn:t};g.length>0&&(b[this.phonesAttribute]=JSON.stringify(g));try{this.logger.info({...c,result:"writing_fqdn",dn:v,hasPhones:g.length>0}),await this.server.ldap.system.modify(v,{replace:b},void 0,{declaredClasses:this.config.user_class}),this.logger.info({...c,result:"written_fqdn",dn:v})}catch(e){this.logger.error({...c,event:"writeFqdn",dn:v,error:`${e}`})}await this.publishUserCreated({twakeId:t,orgDomain:l,orgId:a.orgId,fqdn:w,email:o,mobile:h}),this.logger.info({...c,result:"success",fqdn:w})}async captureDelete(e,t){const r=this.baseResolver.userBase(t);if(this.logger.info({plugin:this.name,event:"captureDelete",id:e,base:r,hasManagerUrl:Boolean(this.managerUrl),hasUserBranch:Boolean(this.userBranch)}),!this.managerUrl||!this.userBranch)return void this.logger.info({plugin:this.name,event:"captureDelete",id:e,base:r,result:"skipped",reason:"missing_configuration"});if(!this.isAtOrUnder(r,this.userBranch))return void this.logger.info({plugin:this.name,event:"captureDelete",id:e,base:r,result:"skipped",reason:"outside_b2b_branch"});const s=`${this.rdnAttribute}=${i(e)},${r}`;try{this.logger.info({plugin:this.name,event:"captureDelete",id:e,dn:s,result:"searching"});const t=await this.server.ldap.system.search({paged:!1,scope:"base",attributes:[this.fqdnAttribute,"mail"]},s),r=t.searchEntries?.[0],i=r?firstValue(r[this.fqdnAttribute]):void 0,n=i?i.trim().replace(/^[a-z][a-z0-9+.-]*:\/\//i,"").split("/")[0]:void 0;if(!n)return void this.logger.info({plugin:this.name,event:"captureDelete",id:e,dn:s,result:"skipped",reason:"missing_fqdn"});const a=r?firstValue(r.mail):void 0,o=a?emailDomain(a):"";this.deleteStash.set(e,{fqdn:n,domain:o}),this.logger.info({plugin:this.name,event:"captureDelete",id:e,dn:s,fqdn:n,domain:o,result:"stashed"})}catch(t){this.logger.warn({plugin:this.name,event:"captureDelete",id:e,error:`${t}`})}}async deprovision(e){this.logger.info({plugin:this.name,event:"deprovision",id:e});const t=this.deleteStash.get(e);if(!t)return void this.logger.info({plugin:this.name,event:"deprovision",id:e,result:"skipped",reason:"no_stashed_context"});this.deleteStash.delete(e);const r={plugin:this.name,event:"deprovision",id:e,fqdn:t.fqdn};let i=!1;try{this.logger.info({...r,result:"resolving_uuid"});const e=(await this.findInstanceByFqdn(t.fqdn,"_id"))?._id;e?(this.logger.info({...r,result:"deleting_instance",uuid:e}),await this.deleteInstance(e),i=!0,this.logger.info({...r,result:"deleted",uuid:e})):this.logger.warn({...r,result:"not_found"})}catch(e){this.logger.error({...r,result:"error",error:`${e}`})}i&&await this.publishUserDeleted(t.fqdn,t.domain)}async createInstance(e){this.logger.info({plugin:this.name,event:"createInstance",url:`${this.managerUrl}/api/v1/instances`});const t=await this.fetch(`${this.managerUrl}/api/v1/instances`,{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${this.managerToken}`},body:JSON.stringify(e)});if(!t.ok){const e=(await t.text().catch(()=>"")).slice(0,o);throw this.logger.error({plugin:this.name,event:"createInstance",result:"error",http_status:t.status,http_status_text:t.statusText,body:e||void 0}),new Error(`Cloudery createInstance HTTP ${t.status}`)}const r=await t.json();return this.logger.info({plugin:this.name,event:"createInstance",result:"success",id:r.id,fqdn:r.fqdn,workflow:r.workflow}),r}async waitForWorkflow(t){this.logger.info({plugin:this.name,event:"waitForWorkflow",workflow:t,maxPollAttempts:this.maxPollAttempts,pollIntervalMs:this.pollIntervalMs});for(let r=1;r<=this.maxPollAttempts;r++){try{const e=await this.fetch(`${this.managerUrl}/api/v1/workflows/${encodeURIComponent(t)}`,{headers:{Authorization:`Bearer ${this.managerToken}`}});if(e.ok){const i=await e.json();if(this.logger.info({plugin:this.name,event:"waitForWorkflow",workflow:t,attempt:r,status:i.status}),"succeeded"===i.status)return this.logger.info({plugin:this.name,event:"waitForWorkflow",workflow:t,attempt:r,result:"success"}),!0;if("failed"===i.status||"error"===i.status)return this.logger.warn({plugin:this.name,event:"waitForWorkflow",workflow:t,attempt:r,result:"failed",status:i.status}),!1}else{const i=(await e.text().catch(()=>"")).slice(0,o);this.logger.info({plugin:this.name,event:"waitForWorkflow",workflow:t,attempt:r,result:"http_error",http_status:e.status,http_status_text:e.statusText,body:i||void 0})}}catch(e){this.logger.info({plugin:this.name,event:"waitForWorkflow",workflow:t,attempt:r,error:`${e}`})}r<this.maxPollAttempts&&(this.logger.info({plugin:this.name,event:"waitForWorkflow",workflow:t,attempt:r,result:"retrying"}),await e(this.pollIntervalMs))}return this.logger.info({plugin:this.name,event:"waitForWorkflow",workflow:t,result:"timeout",maxPollAttempts:this.maxPollAttempts}),!1}async findInstanceByFqdn(e,t){this.logger.info({plugin:this.name,event:"findInstanceByFqdn",fqdn:e});const r=new URL(`${this.managerUrl}/api/v2/instances`);r.searchParams.set("fqdn",e),t&&r.searchParams.set("only",t),r.searchParams.set("limit","1");const i=await this.fetch(r.toString(),{headers:{Authorization:`Bearer ${this.managerToken}`}});if(!i.ok){const t=(await i.text().catch(()=>"")).slice(0,o);throw this.logger.error({plugin:this.name,event:"findInstanceByFqdn",fqdn:e,result:"error",http_status:i.status,http_status_text:i.statusText,body:t||void 0}),new Error(`Cloudery searchInstances HTTP ${i.status}`)}const s=await i.json(),n=s.items?.[0]??null,a=n?._id;return this.logger.info({plugin:this.name,event:"findInstanceByFqdn",fqdn:e,result:a?"found":"not_found",uuid:a||void 0}),n}async deleteInstance(e){const t=new URL(`${this.managerUrl}/api/v1/instances/${encodeURIComponent(e)}`);t.searchParams.set("user_request","true"),this.logger.info({plugin:this.name,event:"deleteInstance",uuid:e,url:t.toString()});const r=await this.fetch(t.toString(),{method:"DELETE",headers:{Authorization:`Bearer ${this.managerToken}`}});if(!r.ok){const t=(await r.text().catch(()=>"")).slice(0,o);throw this.logger.error({plugin:this.name,event:"deleteInstance",uuid:e,result:"error",http_status:r.status,http_status_text:r.statusText,body:t||void 0}),new Error(`Cloudery deleteInstance HTTP ${r.status}`)}this.logger.info({plugin:this.name,event:"deleteInstance",uuid:e,result:"success",http_status:r.status})}async publishUserCreated(e){const t=this.requirePlugin("rabbitmq");if(this.logger.info({plugin:this.name,event:"publishUserCreated",twakeId:e.twakeId,orgId:e.orgId,orgDomain:e.orgDomain,hasRabbitmq:Boolean(t)}),!t)return void this.logger.info({plugin:this.name,event:"publishUserCreated",twakeId:e.twakeId,result:"skipped",reason:"missing_rabbitmq"});const r={twakeId:e.twakeId,domain:e.orgDomain,organizationDomain:e.orgDomain,workplaceFqdn:e.fqdn,organizationId:e.orgId};e.email&&(r.internalEmail=e.email),e.mobile&&(r.mobile=e.mobile);try{await t.publish(this.authExchange,this.userCreatedRoutingKey,r),this.logger.info({plugin:this.name,event:"publishUserCreated",twakeId:e.twakeId,exchange:this.authExchange,routingKey:this.userCreatedRoutingKey,result:"success"})}catch(t){this.logger.error({plugin:this.name,event:"publishUserCreated",twakeId:e.twakeId,error:`${t}`})}}async publishUserDeleted(e,t){if(!this.userDeletedRoutingKey)return;const r=this.requirePlugin("rabbitmq");if(this.logger.info({plugin:this.name,event:"publishUserDeleted",workplaceFqdn:e,domain:t,hasRabbitmq:Boolean(r)}),r)try{await r.publish(this.b2bExchange,this.userDeletedRoutingKey,{workplaceFqdn:e,domain:t}),this.logger.info({plugin:this.name,event:"publishUserDeleted",workplaceFqdn:e,exchange:this.b2bExchange,routingKey:this.userDeletedRoutingKey,result:"success"})}catch(t){this.logger.error({plugin:this.name,event:"publishUserDeleted",workplaceFqdn:e,error:`${t}`})}else this.logger.info({plugin:this.name,event:"publishUserDeleted",workplaceFqdn:e,result:"skipped",reason:"missing_rabbitmq"})}readHeader(e,t){if(!e)return;const r=e.get?e.get(t):void 0;if("string"==typeof r&&r.length>0)return r;const i=e.query?.[t];return"string"==typeof i&&i.length>0?i:void 0}resolveRole(e,t){const r=this.readHeader(e,this.orgRoleHeader)?.trim().toLowerCase();return r?a.has(r)?(this.logger.debug({plugin:this.name,event:"resolveRole",userName:t,input:r,result:"accepted",role:r}),r):(this.logger.warn({plugin:this.name,event:"captureCreate",userName:t,message:`Unknown ${this.orgRoleHeader} "${r}" — falling back to ${this.defaultOrgRole}`}),this.logger.debug({plugin:this.name,event:"resolveRole",userName:t,input:r,result:"default",role:this.defaultOrgRole}),this.defaultOrgRole):(this.logger.debug({plugin:this.name,event:"resolveRole",userName:t,result:"default",role:this.defaultOrgRole}),this.defaultOrgRole)}isAtOrUnder(e,t){return e.trim().toLowerCase()===t.trim().toLowerCase()||s(e,t)}pruneStash(){const e=Date.now()-3e5;for(const[t,r]of this.createStash)r.ts<e&&this.createStash.delete(t)}normalizeUserName(e){"string"==typeof e.userName&&(e.userName=e.userName.toLowerCase())}extractId(e){return"string"==typeof e.userName&&e.userName.length>0?e.userName:"string"==typeof e.id&&e.id.length>0?e.id:null}extractPrimaryEmail(e){const t=e.emails;if(!t||0===t.length)return null;const r=(t.find(e=>e.primary)||t[0]).value;return"string"==typeof r&&r.length>0?r:null}extractPrimaryPhone(e){const t=e.phoneNumbers;if(!t||0===t.length)return null;const r=(t.find(e=>e.primary)||t[0]).value;return"string"==typeof r&&r.length>0?r:null}extractPhones(e){const t=e.phoneNumbers;return t&&0!==t.length?t.filter(e=>"string"==typeof e.value&&e.value.trim().length>0).map(e=>({number:e.value.trim(),primary:Boolean(e.primary)})):[]}extractPublicName(e){if("string"==typeof e.displayName&&e.displayName.trim().length>0)return e.displayName.trim();const t=e.name?.formatted;if("string"==typeof t&&t.trim().length>0)return t.trim();const r=`${e.name?.givenName?.trim()??""} ${e.name?.familyName?.trim()??""}`.trim();return r.length>0?r:null}extractLocale(e){return"string"==typeof e.locale&&e.locale.length>0?e.locale:"string"==typeof e.preferredLanguage&&e.preferredLanguage.length>0?e.preferredLanguage:this.defaultLocale}}function emailDomain(e){const t=e.lastIndexOf("@");return t>=0?e.slice(t+1):""}function firstValue(e){if(Array.isArray(e)){const t=e[0];return"string"==typeof t?t:void 0}return"string"==typeof e?e:void 0}export{ClouderyProvision as default};
@@ -1 +1 @@
1
- import{Buffer as t}from"buffer";import e from"node-fetch";import s from"../../abstract/plugin.js";class CozyProvision extends s{constructor(e){super(e),this.name="cozyProvision",this.roles=["consistency"],this.dependencies={rabbitmq:"core/rabbitmq"},this.hooks={scimusercreatedone:async t=>{await this.createCozyInstance(t)&&await this.publishUserCreated(t)},scimuserdeletedone:async t=>{await this.destroyCozyInstance(t)&&await this.publishUserDeleted(t)}},this.cozyAdminUrl=(this.config.cozy_admin_url||"").replace(/\/$/,""),this.cozyAdminUser=this.config.cozy_admin_user||"admin",this.cozyAdminPassphrase=this.config.cozy_admin_passphrase||"",this.cozyOrgId=this.config.cozy_org_id||"",this.cozyOrgDomain=this.config.cozy_org_domain||"",this.cozyDefaultLocale=this.config.cozy_default_locale||"fr",this.cozyContextName=this.config.cozy_context_name||"default",this.cozyApps=this.config.cozy_apps??"",this.authExchange=this.config.cozy_auth_exchange||"auth",this.b2bExchange=this.config.cozy_b2b_exchange||"b2b",this.userCreatedRoutingKey=this.config.cozy_user_created_routing_key||"user.created",this.userDeletedRoutingKey=this.config.cozy_user_deleted_routing_key??"domain.user.deleted",this.cozyAdminAuthHeader=`Basic ${t.from(`${this.cozyAdminUser}:${this.cozyAdminPassphrase}`).toString("base64")}`,this.cozyAdminUrl||this.logger.warn(`${this.name}: cozy_admin_url is empty — Cozy instance creation will be skipped`),this.cozyOrgDomain||this.logger.warn(`${this.name}: cozy_org_domain is empty — workplaceFqdn cannot be composed`)}async createCozyInstance(t){if(!this.cozyAdminUrl)return!1;const s=this.extractId(t);if(!s)return this.logger.warn({plugin:this.name,event:"createCozyInstance",message:"user has no userName/id — skipping"}),!1;if(!this.cozyOrgDomain)return this.logger.error({plugin:this.name,event:"createCozyInstance",id:s,message:"cozy_org_domain not configured — cannot compose Domain"}),!1;const r=`${s}.${this.cozyOrgDomain}`,i=new URLSearchParams;i.set("Domain",r),i.set("Locale",this.extractLocale(t));const n=this.extractPrimaryEmail(t);n&&i.set("Email",n);const o=this.extractPublicName(t);o&&i.set("PublicName",o);const a=this.extractPrimaryPhone(t);a&&i.set("Phone",a),this.cozyOrgId&&i.set("OrgID",this.cozyOrgId),this.cozyOrgDomain&&i.set("OrgDomain",this.cozyOrgDomain),this.cozyContextName&&i.set("ContextName",this.cozyContextName),this.cozyApps&&i.set("Apps",this.cozyApps),i.set("OIDCID",s);const c=`${this.cozyAdminUrl}/instances?${i.toString()}`,h={plugin:this.name,event:"createCozyInstance",id:s,domain:r,email:n||void 0};try{const t=await e(c,{method:"POST",headers:{Authorization:this.cozyAdminAuthHeader,Accept:"application/json"}});return t.ok?(this.logger.info({...h,result:"success",http_status:t.status}),await this.markInstanceOnboarded(r),!0):409===t.status?(this.logger.info({...h,result:"already_exists",http_status:t.status}),!0):(this.logger.error({...h,result:"error",http_status:t.status,http_status_text:t.statusText}),!1)}catch(t){return this.logger.error({...h,result:"error",error:`${t}`}),!1}}async markInstanceOnboarded(t){const s=`${this.cozyAdminUrl}/instances/${encodeURIComponent(t)}?OnboardingFinished=true`,r={plugin:this.name,event:"markInstanceOnboarded",domain:t};try{const t=await e(s,{method:"PATCH",headers:{Authorization:this.cozyAdminAuthHeader,Accept:"application/json"}});if(!t.ok)return void this.logger.error({...r,result:"error",http_status:t.status,http_status_text:t.statusText});this.logger.info({...r,result:"success",http_status:t.status})}catch(t){this.logger.error({...r,result:"error",error:`${t}`})}}async destroyCozyInstance(t){if(!this.cozyAdminUrl)return!1;if(!this.cozyOrgDomain)return this.logger.error({plugin:this.name,event:"destroyCozyInstance",id:t,message:"cozy_org_domain not configured — cannot compose Domain"}),!1;const s=`${t}.${this.cozyOrgDomain}`,r=`${this.cozyAdminUrl}/instances/${encodeURIComponent(s)}`,i={plugin:this.name,event:"destroyCozyInstance",id:t,domain:s};try{const t=await e(r,{method:"DELETE",headers:{Authorization:this.cozyAdminAuthHeader,Accept:"application/json"}});return t.ok||404===t.status?(this.logger.info({...i,result:t.ok?"success":"not_found",http_status:t.status}),!0):(this.logger.error({...i,result:"error",http_status:t.status,http_status_text:t.statusText}),!1)}catch(t){return this.logger.error({...i,result:"error",error:`${t}`}),!1}}async publishUserCreated(t){const e=this.rabbitmq();if(!e)return;const s=this.extractId(t);if(!s)return;const r=this.extractPrimaryEmail(t),i=this.extractPrimaryPhone(t),n={twakeId:s,domain:this.cozyOrgDomain,organizationDomain:this.cozyOrgDomain,workplaceFqdn:`${s}.${this.cozyOrgDomain}`};r&&(n.internalEmail=r),i&&(n.mobile=i);try{await e.publish(this.authExchange,this.userCreatedRoutingKey,n),this.logger.info({plugin:this.name,event:"publishUserCreated",result:"success",exchange:this.authExchange,routingKey:this.userCreatedRoutingKey,twakeId:s})}catch(t){this.logger.error({plugin:this.name,event:"publishUserCreated",twakeId:s,error:`${t}`})}}async publishUserDeleted(t){if(!this.userDeletedRoutingKey)return;const e=this.rabbitmq();if(!e)return;if(!this.cozyOrgDomain)return void this.logger.error({plugin:this.name,event:"publishUserDeleted",id:t,message:"cozy_org_domain not configured — cannot compose workplaceFqdn"});const s={workplaceFqdn:`${t}.${this.cozyOrgDomain}`,domain:this.cozyOrgDomain};try{await e.publish(this.b2bExchange,this.userDeletedRoutingKey,s),this.logger.info({plugin:this.name,event:"publishUserDeleted",result:"success",exchange:this.b2bExchange,routingKey:this.userDeletedRoutingKey,workplaceFqdn:s.workplaceFqdn})}catch(e){this.logger.error({plugin:this.name,event:"publishUserDeleted",id:t,error:`${e}`})}}rabbitmq(){return this.requirePlugin("rabbitmq")}extractId(t){return"string"==typeof t.userName&&t.userName.length>0?t.userName:"string"==typeof t.id&&t.id.length>0?t.id:null}extractPrimaryEmail(t){const e=t.emails;if(!e||0===e.length)return null;const s=(e.find(t=>t.primary)||e[0]).value;return"string"==typeof s&&s.length>0?s:null}extractPrimaryPhone(t){const e=t.phoneNumbers;if(!e||0===e.length)return null;const s=(e.find(t=>t.primary)||e[0]).value;return"string"==typeof s&&s.length>0?s:null}extractPublicName(t){if("string"==typeof t.displayName&&t.displayName.trim().length>0)return t.displayName.trim();const e=t.name?.formatted;if("string"==typeof e&&e.trim().length>0)return e.trim();const s=`${t.name?.givenName?.trim()??""} ${t.name?.familyName?.trim()??""}`.trim();return s.length>0?s:this.extractId(t)}extractLocale(t){return"string"==typeof t.locale&&t.locale.length>0?t.locale:"string"==typeof t.preferredLanguage&&t.preferredLanguage.length>0?t.preferredLanguage:this.cozyDefaultLocale}}export{CozyProvision as default};
1
+ import{Buffer as t}from"buffer";import e from"../../abstract/plugin.js";class CozyProvision extends e{constructor(e){super(e),this.name="cozyProvision",this.roles=["consistency"],this.dependencies={rabbitmq:"core/rabbitmq"},this.hooks={scimusercreatedone:async t=>{await this.createCozyInstance(t)&&await this.publishUserCreated(t)},scimuserdeletedone:async t=>{await this.destroyCozyInstance(t)&&await this.publishUserDeleted(t)}},this.cozyAdminUrl=(this.config.cozy_admin_url||"").replace(/\/$/,""),this.cozyAdminUser=this.config.cozy_admin_user||"admin",this.cozyAdminPassphrase=this.config.cozy_admin_passphrase||"",this.cozyOrgId=this.config.cozy_org_id||"",this.cozyOrgDomain=this.config.cozy_org_domain||"",this.cozyDefaultLocale=this.config.cozy_default_locale||"fr",this.cozyContextName=this.config.cozy_context_name||"default",this.cozyApps=this.config.cozy_apps??"",this.authExchange=this.config.cozy_auth_exchange||"auth",this.b2bExchange=this.config.cozy_b2b_exchange||"b2b",this.userCreatedRoutingKey=this.config.cozy_user_created_routing_key||"user.created",this.userDeletedRoutingKey=this.config.cozy_user_deleted_routing_key??"domain.user.deleted",this.cozyAdminAuthHeader=`Basic ${t.from(`${this.cozyAdminUser}:${this.cozyAdminPassphrase}`).toString("base64")}`,this.cozyAdminUrl||this.logger.warn(`${this.name}: cozy_admin_url is empty — Cozy instance creation will be skipped`),this.cozyOrgDomain||this.logger.warn(`${this.name}: cozy_org_domain is empty — workplaceFqdn cannot be composed`)}async createCozyInstance(t){if(!this.cozyAdminUrl)return!1;const e=this.extractId(t);if(!e)return this.logger.warn({plugin:this.name,event:"createCozyInstance",message:"user has no userName/id — skipping"}),!1;if(!this.cozyOrgDomain)return this.logger.error({plugin:this.name,event:"createCozyInstance",id:e,message:"cozy_org_domain not configured — cannot compose Domain"}),!1;const s=`${e}.${this.cozyOrgDomain}`,r=new URLSearchParams;r.set("Domain",s),r.set("Locale",this.extractLocale(t));const i=this.extractPrimaryEmail(t);i&&r.set("Email",i);const n=this.extractPublicName(t);n&&r.set("PublicName",n);const o=this.extractPrimaryPhone(t);o&&r.set("Phone",o),this.cozyOrgId&&r.set("OrgID",this.cozyOrgId),this.cozyOrgDomain&&r.set("OrgDomain",this.cozyOrgDomain),this.cozyContextName&&r.set("ContextName",this.cozyContextName),this.cozyApps&&r.set("Apps",this.cozyApps),r.set("OIDCID",e);const a=`${this.cozyAdminUrl}/instances?${r.toString()}`,c={plugin:this.name,event:"createCozyInstance",id:e,domain:s,email:i||void 0};try{const t=await this.fetch(a,{method:"POST",headers:{Authorization:this.cozyAdminAuthHeader,Accept:"application/json"}});return t.ok?(this.logger.info({...c,result:"success",http_status:t.status}),await this.markInstanceOnboarded(s),!0):409===t.status?(this.logger.info({...c,result:"already_exists",http_status:t.status}),!0):(this.logger.error({...c,result:"error",http_status:t.status,http_status_text:t.statusText}),!1)}catch(t){return this.logger.error({...c,result:"error",error:`${t}`}),!1}}async markInstanceOnboarded(t){const e=`${this.cozyAdminUrl}/instances/${encodeURIComponent(t)}?OnboardingFinished=true`,s={plugin:this.name,event:"markInstanceOnboarded",domain:t};try{const t=await this.fetch(e,{method:"PATCH",headers:{Authorization:this.cozyAdminAuthHeader,Accept:"application/json"}});if(!t.ok)return void this.logger.error({...s,result:"error",http_status:t.status,http_status_text:t.statusText});this.logger.info({...s,result:"success",http_status:t.status})}catch(t){this.logger.error({...s,result:"error",error:`${t}`})}}async destroyCozyInstance(t){if(!this.cozyAdminUrl)return!1;if(!this.cozyOrgDomain)return this.logger.error({plugin:this.name,event:"destroyCozyInstance",id:t,message:"cozy_org_domain not configured — cannot compose Domain"}),!1;const e=`${t}.${this.cozyOrgDomain}`,s=`${this.cozyAdminUrl}/instances/${encodeURIComponent(e)}`,r={plugin:this.name,event:"destroyCozyInstance",id:t,domain:e};try{const t=await this.fetch(s,{method:"DELETE",headers:{Authorization:this.cozyAdminAuthHeader,Accept:"application/json"}});return t.ok||404===t.status?(this.logger.info({...r,result:t.ok?"success":"not_found",http_status:t.status}),!0):(this.logger.error({...r,result:"error",http_status:t.status,http_status_text:t.statusText}),!1)}catch(t){return this.logger.error({...r,result:"error",error:`${t}`}),!1}}async publishUserCreated(t){const e=this.rabbitmq();if(!e)return;const s=this.extractId(t);if(!s)return;const r=this.extractPrimaryEmail(t),i=this.extractPrimaryPhone(t),n={twakeId:s,domain:this.cozyOrgDomain,organizationDomain:this.cozyOrgDomain,workplaceFqdn:`${s}.${this.cozyOrgDomain}`};r&&(n.internalEmail=r),i&&(n.mobile=i);try{await e.publish(this.authExchange,this.userCreatedRoutingKey,n),this.logger.info({plugin:this.name,event:"publishUserCreated",result:"success",exchange:this.authExchange,routingKey:this.userCreatedRoutingKey,twakeId:s})}catch(t){this.logger.error({plugin:this.name,event:"publishUserCreated",twakeId:s,error:`${t}`})}}async publishUserDeleted(t){if(!this.userDeletedRoutingKey)return;const e=this.rabbitmq();if(!e)return;if(!this.cozyOrgDomain)return void this.logger.error({plugin:this.name,event:"publishUserDeleted",id:t,message:"cozy_org_domain not configured — cannot compose workplaceFqdn"});const s={workplaceFqdn:`${t}.${this.cozyOrgDomain}`,domain:this.cozyOrgDomain};try{await e.publish(this.b2bExchange,this.userDeletedRoutingKey,s),this.logger.info({plugin:this.name,event:"publishUserDeleted",result:"success",exchange:this.b2bExchange,routingKey:this.userDeletedRoutingKey,workplaceFqdn:s.workplaceFqdn})}catch(e){this.logger.error({plugin:this.name,event:"publishUserDeleted",id:t,error:`${e}`})}}rabbitmq(){return this.requirePlugin("rabbitmq")}extractId(t){return"string"==typeof t.userName&&t.userName.length>0?t.userName:"string"==typeof t.id&&t.id.length>0?t.id:null}extractPrimaryEmail(t){const e=t.emails;if(!e||0===e.length)return null;const s=(e.find(t=>t.primary)||e[0]).value;return"string"==typeof s&&s.length>0?s:null}extractPrimaryPhone(t){const e=t.phoneNumbers;if(!e||0===e.length)return null;const s=(e.find(t=>t.primary)||e[0]).value;return"string"==typeof s&&s.length>0?s:null}extractPublicName(t){if("string"==typeof t.displayName&&t.displayName.trim().length>0)return t.displayName.trim();const e=t.name?.formatted;if("string"==typeof e&&e.trim().length>0)return e.trim();const s=`${t.name?.givenName?.trim()??""} ${t.name?.familyName?.trim()??""}`.trim();return s.length>0?s:this.extractId(t)}extractLocale(t){return"string"==typeof t.locale&&t.locale.length>0?t.locale:"string"==typeof t.preferredLanguage&&t.preferredLanguage.length>0?t.preferredLanguage:this.cozyDefaultLocale}}export{CozyProvision as default};
@@ -1 +1 @@
1
- import t from"node-fetch";import{TwakePlugin as e}from"../../abstract/twakePlugin.js";class Drive extends e{constructor(t){super(t,"twake_drive_webadmin_url","twake_drive_webadmin_token","twake_drive_concurrency"),this.name="drive",this.roles=["consistency"],this.dependencies={onLdapChange:"core/ldap/onChange"},this.hooks={onLdapMailChange:async(t,e,i)=>{const n=e?Array.isArray(e)?String(e[0]):String(e):null,a=i?Array.isArray(i)?String(i[0]):String(i):null;if(!n)return void this.logger.debug(`Skipping mail change for ${t}: oldmail is empty (mail attribute was added, not changed)`);if(!a)return void this.logger.debug(`Skipping mail change for ${t}: newmail is empty (mail attribute was deleted)`);const r=await this.getCozyDomain(t);r?await this.updateCozyInstance("onLdapMailChange",r,t,{Email:a}):this.logger.debug(`Skipping mail change for ${t}: no Cozy domain attribute (${this.cozyDomainAttr})`)},onLdapDisplayNameChange:async(t,e,i)=>{const n=await this.ldapGetAttributes(t,[this.cozyDomainAttr,this.displayNameAttr,"cn","givenName","sn"]);if(!n)return void this.logger.warn(`Cannot update Cozy display name: entry not found for ${t}`);const a=this.attributeToString(n[this.cozyDomainAttr]);if(!a)return void this.logger.debug(`Skipping display name change for ${t}: no Cozy domain attribute (${this.cozyDomainAttr})`);const r=this.getDisplayNameFromAttributes(n);r?await this.updateCozyInstance("onLdapDisplayNameChange",a,t,{PublicName:r}):this.logger.warn(`Cannot update Cozy display name: no display name found for ${t}`)},onLdapDriveQuotaChange:async(t,e,i)=>{if(null===i)return void this.logger.debug(`Skipping drive quota change for ${t}: quota was deleted`);const n=await this.getCozyDomain(t);n?await this.updateCozyInstance("onLdapDriveQuotaChange",n,t,{DiskQuota:String(i)}):this.logger.debug(`Skipping drive quota change for ${t}: no Twake Drive domain attribute (${this.cozyDomainAttr})`)}},this.cozyDomainAttr=this.config.twake_drive_domain_attribute||"twakeCozyDomain",this.defaultDomainTemplate=this.config.twake_drive_default_domain_template||"",this.templateAttrs=this.defaultDomainTemplate?[...this.defaultDomainTemplate.matchAll(/\{(\w+)\}/g)].map(t=>t[1]):[],this.driveQuotaAttr=this.config.drive_quota_attribute||"twakeDriveQuota"}createHeaders(t){const e={};if(this.webadminToken){const t=Buffer.from(`:${this.webadminToken}`).toString("base64");e.Authorization=`Basic ${t}`}return t&&(e["Content-Type"]=t),e}isValidCozyDomain(t){if(!t||t.length>253)return!1;if(t.includes("/")||t.includes("\\")||t.includes("?")||t.includes("#"))return!1;return/^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/.test(t)}async getCozyDomain(t){const e=[this.cozyDomainAttr,...this.templateAttrs],i=await this.ldapGetAttributes(t,e),n=i?this.attributeToString(i[this.cozyDomainAttr]):null;if(n)return n;if(this.defaultDomainTemplate&&i){let t=this.defaultDomainTemplate;for(const e of this.templateAttrs){const n=this.attributeToString(i[e]);if(!n)return null;t=t.replace(`{${e}}`,n)}return t}return null}async updateCozyInstance(e,i,n,a){if(!this.isValidCozyDomain(i))return void this.logger.error({plugin:this.name,event:e,result:"error",dn:n,cozyDomain:i,error:"Invalid Cozy domain format (potential URL injection attempt)"});const r=new URL(`${this.webadminUrl}/instances/${i}`);r.searchParams.set("FromCloudery","true");for(const[t,e]of Object.entries(a))r.searchParams.set(t,e);return this.requestLimit(async()=>{const s={plugin:this.name,event:e,result:"error",dn:n,cozyDomain:i,...a};try{const e=await t(r.toString(),{method:"PATCH",headers:this.createHeaders("application/json")});e.ok?this.logger.info({...s,result:"success",http_status:e.status}):404===e.status?this.logger.debug({...s,result:"ignored",http_status:e.status,http_status_text:e.statusText,message:"Cozy instance not found (may not be provisioned yet)"}):this.logger.error({...s,http_status:e.status,http_status_text:e.statusText,url:r.toString()})}catch(t){this.logger.error({...s,error:t,url:r.toString()})}})}getDisplayNameFromAttributes(t){const e=this.attributeToString(t[this.displayNameAttr]);if(e)return e;const i=this.attributeToString(t.cn);if(i)return i;const n=this.attributeToString(t.givenName),a=this.attributeToString(t.sn);if(n||a){const t=[];return n&&t.push(n),a&&t.push(a),t.join(" ")}return null}async getDisplayNameFromDN(t){const e=[this.displayNameAttr,"cn","givenName","sn"],i=await this.ldapGetAttributes(t,e);return i?this.getDisplayNameFromAttributes(i):null}async getMailFromDN(t){const e=await this.ldapGetAttributes(t,[this.mailAttr]);return e?this.attributeToString(e[this.mailAttr]):null}async getDriveQuotaFromDN(t){const e=await this.ldapGetAttributes(t,[this.driveQuotaAttr]);if(!e)return null;const i=this.attributeToString(e[this.driveQuotaAttr]);return i?Number(i):null}async syncUserToCozy(t){const e=await this.ldapGetAttributes(t,[this.cozyDomainAttr,this.mailAttr,this.displayNameAttr,this.driveQuotaAttr,"cn","givenName","sn"]);if(!e)return this.logger.error(`Cannot sync user: entry not found for ${t}`),!1;const i=this.attributeToString(e[this.cozyDomainAttr]);if(!i)return this.logger.error(`Cannot sync user: no Twake Drive domain attribute for ${t}`),!1;const n=this.attributeToString(e[this.mailAttr]),a=this.getDisplayNameFromAttributes(e),r=this.attributeToString(e[this.driveQuotaAttr]),s={};return n&&(s.Email=n),a&&(s.PublicName=a),r&&(s.DiskQuota=r),0===Object.keys(s).length?(this.logger.warn(`No attributes to sync for ${t}`),!1):(await this.updateCozyInstance("syncUserToCozy",i,t,s),!0)}async blockInstance(t,e){const i=await this.getCozyDomain(t);if(!i)return this.logger.error(`Cannot block instance: no Cozy domain for ${t}`),!1;const n={Blocked:"true"};return e&&(n.BlockingReason=e),await this.updateCozyInstance("blockInstance",i,t,n),!0}async unblockInstance(t){const e=await this.getCozyDomain(t);return e?(await this.updateCozyInstance("unblockInstance",e,t,{Blocked:"false"}),!0):(this.logger.error(`Cannot unblock instance: no Cozy domain for ${t}`),!1)}async deleteInstance(e){const i=await this.getCozyDomain(e);if(!i)return this.logger.error(`Cannot delete instance: no Cozy domain for ${e}`),!1;if(!this.isValidCozyDomain(i))return this.logger.error({plugin:this.name,event:"deleteInstance",result:"error",dn:e,cozyDomain:i,error:"Invalid Cozy domain format (potential URL injection attempt)"}),!1;const n=`${this.webadminUrl}/instances/${i}`;return this.requestLimit(async()=>{try{const a=await t(n,{method:"DELETE",headers:this.createHeaders()});return a.ok?(this.logger.info({plugin:this.name,event:"deleteInstance",result:"success",dn:e,cozyDomain:i,http_status:a.status}),!0):404===a.status?(this.logger.info({plugin:this.name,event:"deleteInstance",result:"ignored",dn:e,cozyDomain:i,http_status:404,message:"Cozy instance not found (already deleted or never created)"}),!0):(this.logger.error({plugin:this.name,event:"deleteInstance",result:"error",dn:e,cozyDomain:i,http_status:a.status,http_status_text:a.statusText,url:n}),!1)}catch(t){return this.logger.error({plugin:this.name,event:"deleteInstance",result:"error",dn:e,cozyDomain:i,error:t,url:n}),!1}})}}export{Drive as default};
1
+ import{TwakePlugin as t}from"../../abstract/twakePlugin.js";class Drive extends t{constructor(t){super(t,"twake_drive_webadmin_url","twake_drive_webadmin_token","twake_drive_concurrency"),this.name="drive",this.roles=["consistency"],this.dependencies={onLdapChange:"core/ldap/onChange"},this.hooks={onLdapMailChange:async(t,e,i)=>{const n=e?Array.isArray(e)?String(e[0]):String(e):null,a=i?Array.isArray(i)?String(i[0]):String(i):null;if(!n)return void this.logger.debug(`Skipping mail change for ${t}: oldmail is empty (mail attribute was added, not changed)`);if(!a)return void this.logger.debug(`Skipping mail change for ${t}: newmail is empty (mail attribute was deleted)`);const r=await this.getCozyDomain(t);r?await this.updateCozyInstance("onLdapMailChange",r,t,{Email:a}):this.logger.debug(`Skipping mail change for ${t}: no Cozy domain attribute (${this.cozyDomainAttr})`)},onLdapDisplayNameChange:async(t,e,i)=>{const n=await this.ldapGetAttributes(t,[this.cozyDomainAttr,this.displayNameAttr,"cn","givenName","sn"]);if(!n)return void this.logger.warn(`Cannot update Cozy display name: entry not found for ${t}`);const a=this.attributeToString(n[this.cozyDomainAttr]);if(!a)return void this.logger.debug(`Skipping display name change for ${t}: no Cozy domain attribute (${this.cozyDomainAttr})`);const r=this.getDisplayNameFromAttributes(n);r?await this.updateCozyInstance("onLdapDisplayNameChange",a,t,{PublicName:r}):this.logger.warn(`Cannot update Cozy display name: no display name found for ${t}`)},onLdapDriveQuotaChange:async(t,e,i)=>{if(null===i)return void this.logger.debug(`Skipping drive quota change for ${t}: quota was deleted`);const n=await this.getCozyDomain(t);n?await this.updateCozyInstance("onLdapDriveQuotaChange",n,t,{DiskQuota:String(i)}):this.logger.debug(`Skipping drive quota change for ${t}: no Twake Drive domain attribute (${this.cozyDomainAttr})`)}},this.cozyDomainAttr=this.config.twake_drive_domain_attribute||"twakeCozyDomain",this.defaultDomainTemplate=this.config.twake_drive_default_domain_template||"",this.templateAttrs=this.defaultDomainTemplate?[...this.defaultDomainTemplate.matchAll(/\{(\w+)\}/g)].map(t=>t[1]):[],this.driveQuotaAttr=this.config.drive_quota_attribute||"twakeDriveQuota"}createHeaders(t){const e={};if(this.webadminToken){const t=Buffer.from(`:${this.webadminToken}`).toString("base64");e.Authorization=`Basic ${t}`}return t&&(e["Content-Type"]=t),e}isValidCozyDomain(t){if(!t||t.length>253)return!1;if(t.includes("/")||t.includes("\\")||t.includes("?")||t.includes("#"))return!1;return/^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/.test(t)}async getCozyDomain(t){const e=[this.cozyDomainAttr,...this.templateAttrs],i=await this.ldapGetAttributes(t,e),n=i?this.attributeToString(i[this.cozyDomainAttr]):null;if(n)return n;if(this.defaultDomainTemplate&&i){let t=this.defaultDomainTemplate;for(const e of this.templateAttrs){const n=this.attributeToString(i[e]);if(!n)return null;t=t.replace(`{${e}}`,n)}return t}return null}async updateCozyInstance(t,e,i,n){if(!this.isValidCozyDomain(e))return void this.logger.error({plugin:this.name,event:t,result:"error",dn:i,cozyDomain:e,error:"Invalid Cozy domain format (potential URL injection attempt)"});const a=new URL(`${this.webadminUrl}/instances/${e}`);a.searchParams.set("FromCloudery","true");for(const[t,e]of Object.entries(n))a.searchParams.set(t,e);return this.requestLimit(async()=>{const r={plugin:this.name,event:t,result:"error",dn:i,cozyDomain:e,...n};try{const t=await this.fetch(a.toString(),{method:"PATCH",headers:this.createHeaders("application/json")});t.ok?this.logger.info({...r,result:"success",http_status:t.status}):404===t.status?this.logger.debug({...r,result:"ignored",http_status:t.status,http_status_text:t.statusText,message:"Cozy instance not found (may not be provisioned yet)"}):this.logger.error({...r,http_status:t.status,http_status_text:t.statusText,url:a.toString()})}catch(t){this.logger.error({...r,error:t,url:a.toString()})}})}getDisplayNameFromAttributes(t){const e=this.attributeToString(t[this.displayNameAttr]);if(e)return e;const i=this.attributeToString(t.cn);if(i)return i;const n=this.attributeToString(t.givenName),a=this.attributeToString(t.sn);if(n||a){const t=[];return n&&t.push(n),a&&t.push(a),t.join(" ")}return null}async getDisplayNameFromDN(t){const e=[this.displayNameAttr,"cn","givenName","sn"],i=await this.ldapGetAttributes(t,e);return i?this.getDisplayNameFromAttributes(i):null}async getMailFromDN(t){const e=await this.ldapGetAttributes(t,[this.mailAttr]);return e?this.attributeToString(e[this.mailAttr]):null}async getDriveQuotaFromDN(t){const e=await this.ldapGetAttributes(t,[this.driveQuotaAttr]);if(!e)return null;const i=this.attributeToString(e[this.driveQuotaAttr]);return i?Number(i):null}async syncUserToCozy(t){const e=await this.ldapGetAttributes(t,[this.cozyDomainAttr,this.mailAttr,this.displayNameAttr,this.driveQuotaAttr,"cn","givenName","sn"]);if(!e)return this.logger.error(`Cannot sync user: entry not found for ${t}`),!1;const i=this.attributeToString(e[this.cozyDomainAttr]);if(!i)return this.logger.error(`Cannot sync user: no Twake Drive domain attribute for ${t}`),!1;const n=this.attributeToString(e[this.mailAttr]),a=this.getDisplayNameFromAttributes(e),r=this.attributeToString(e[this.driveQuotaAttr]),s={};return n&&(s.Email=n),a&&(s.PublicName=a),r&&(s.DiskQuota=r),0===Object.keys(s).length?(this.logger.warn(`No attributes to sync for ${t}`),!1):(await this.updateCozyInstance("syncUserToCozy",i,t,s),!0)}async blockInstance(t,e){const i=await this.getCozyDomain(t);if(!i)return this.logger.error(`Cannot block instance: no Cozy domain for ${t}`),!1;const n={Blocked:"true"};return e&&(n.BlockingReason=e),await this.updateCozyInstance("blockInstance",i,t,n),!0}async unblockInstance(t){const e=await this.getCozyDomain(t);return e?(await this.updateCozyInstance("unblockInstance",e,t,{Blocked:"false"}),!0):(this.logger.error(`Cannot unblock instance: no Cozy domain for ${t}`),!1)}async deleteInstance(t){const e=await this.getCozyDomain(t);if(!e)return this.logger.error(`Cannot delete instance: no Cozy domain for ${t}`),!1;if(!this.isValidCozyDomain(e))return this.logger.error({plugin:this.name,event:"deleteInstance",result:"error",dn:t,cozyDomain:e,error:"Invalid Cozy domain format (potential URL injection attempt)"}),!1;const i=`${this.webadminUrl}/instances/${e}`;return this.requestLimit(async()=>{try{const n=await this.fetch(i,{method:"DELETE",headers:this.createHeaders()});return n.ok?(this.logger.info({plugin:this.name,event:"deleteInstance",result:"success",dn:t,cozyDomain:e,http_status:n.status}),!0):404===n.status?(this.logger.info({plugin:this.name,event:"deleteInstance",result:"ignored",dn:t,cozyDomain:e,http_status:404,message:"Cozy instance not found (already deleted or never created)"}),!0):(this.logger.error({plugin:this.name,event:"deleteInstance",result:"error",dn:t,cozyDomain:e,http_status:n.status,http_status_text:n.statusText,url:i}),!1)}catch(n){return this.logger.error({plugin:this.name,event:"deleteInstance",result:"error",dn:t,cozyDomain:e,error:n,url:i}),!1}})}}export{Drive as default};
@@ -1 +1 @@
1
- import t from"node-fetch";import{TwakePlugin as e}from"../../abstract/twakePlugin.js";import{roleAttribute as a}from"../../config/schema.js";import{asyncHandler as i,escapeLdapFilter as s,isDnInBranch as r,parseDn as n,normalizeDn as o,isDummyMemberDn as l}from"../../lib/utils.js";import{ConflictError as d}from"../../lib/errors.js";import{extractLdapCode as m}from"../../lib/ldapCodes.js";import{currentOperation as h}from"../../lib/operation.js";import{wantJson as u}from"../../lib/expressFormatedResponses.js";import{domainBranches as c,mailDomains as g}from"../../lib/mailDomains.js";const p=[400,404],f=[500,502,503,504],pickDefaultIdentity=t=>(Array.isArray(t)?t:[t]).filter(t=>t&&t.id&&!1!==t.mayDelete).sort((t,e)=>(t.sortOrder??0)-(e.sortOrder??0))[0],members=t=>valuesOf(t??void 0,"member")??[],sameAttr=(t,e)=>t.split(";")[0].toLowerCase()===e.split(";")[0].toLowerCase(),valuesOf=(t,e)=>{if(!t)return;const a=Object.keys(t).filter(t=>sameAttr(t,e));return a.length?a.flatMap(e=>{return null==(a=t[e])?[]:(Array.isArray(a)?a:[a]).map(String);var a}):void 0},valuesAfter=(t,e,a,i)=>{let s=valuesOf(t,a)??[];const r=valuesOf(e.add,a);r&&(s=[...s,...r]);const n=valuesOf(e.replace,a);n&&(s=n);const o=e.delete;if(Array.isArray(o))o.some(t=>sameAttr(t,a))&&(s=[]);else{const t=valuesOf(o,a);if(t){const e=new Set(t.map(i));s=e.size?s.filter(t=>!e.has(i(t))):[]}}return s},memberKey=t=>{try{return o(t)}catch{return t.toLowerCase()}},b=["groupofnames","groupofuniquenames","groupofurls"],isGroup=t=>(Array.isArray(t)?t:t?[t]:[]).some(t=>b.includes(String(t).toLowerCase())),sameDn=(t,e)=>{try{return o(t)===o(e)}catch{return!1}};class James extends e{constructor(t){super(t,"james_webadmin_url","james_webadmin_token","james_concurrency"),this.name="james",this.roles=["consistency","api","configurable"],this.dependencies={onLdapChange:"core/ldap/onChange",ldapGroups:"core/ldap/groups"},this.hooks={ldapaddrequest:async([t,e,a])=>{const i=this.teamMailboxDomain(valuesOf(e,this.mailAttr),valuesOf(e,this.mailboxTypeAttr));return i&&await this.assertMembersAt(t,i,valuesOf(e,"member")??[]),void 0!==a?[t,e,a]:[t,e]},ldapmodifyrequest:async([t,e,a,i])=>(await this.guardTeamMailboxChange(t,e),await this.guardDomainChange(t,e),[t,e,a,i]),ldapdeleterequest:async([t,e])=>{for(const e of Array.isArray(t)?t:[t])e&&await this.guardDomainRemoval(e,"delete");return[t,e]},ldaprenamerequest:async([t,e,a])=>(await this.guardDomainMove(t,e),[t,e,a]),ldaprenamedone:async([t,e])=>{void 0===h()&&await this.followMove(t,e)},ldapadddone:([t,e])=>this.provisionAccount(t,e),onLdapMailChange:async(t,e,a)=>{const i=e?Array.isArray(e)?String(e[0]):String(e):null,s=a?Array.isArray(a)?String(a[0]):String(a):null;if(!i)return void this.logger.debug(`Skipping mail rename for ${t}: oldmail is empty (mail attribute was added, not changed)`);if(!s)return void this.logger.debug(`Skipping mail rename for ${t}: newmail is empty (mail attribute was deleted, not changed)`);if(await this.isGroupDn(t))return;const r=await this.defaultSignatures(i),n=await this.ldapGetAttributes(t,[this.forwardAttr,this.displayNameAttr,"cn","givenName","sn",this.mailAttr]);await this.dropStaleForwards(t,s,this.ownForwards(n?.[this.forwardAttr])),await this.callWebAdminApi("onLdapMailChange",`${this.webadminUrl}/users/${i}/rename/${s}?action=rename&force`,"POST",t,null,{oldmail:i,newmail:s},p);try{const e=await this.server.ldap.system.search({paged:!1,scope:"base",attributes:[this.aliasAttr]},t);if(e.searchEntries&&e.searchEntries.length>0){const a=this.getAliases(e.searchEntries[0][this.aliasAttr]);a.length>0&&await Promise.all([...a.map(e=>this.callWebAdminApi("onLdapMailChange-delete",`${this.webadminUrl}/address/aliases/${i}/sources/${e}`,"DELETE",t,null,{oldmail:i,alias:e})),...a.map(e=>this.callWebAdminApi("onLdapMailChange-create",`${this.webadminUrl}/address/aliases/${s}/sources/${e}`,"PUT",t,null,{newmail:s,alias:e},p))])}}catch(t){this.logger.debug("Could not fetch aliases for mail change:",t)}const o=n&&this.getDisplayNameFromAttributes(n);o&&await this.updateJamesIdentity(t,s,o,r)},onLdapAliasChange:async(t,e,a,i)=>{if(await this.isGroupDn(t))return;const s=a.map(t=>this.normalizeAlias(t)),r=i.map(t=>this.normalizeAlias(t)),n=s.filter(t=>!r.includes(t)),o=r.filter(t=>!s.includes(t));await Promise.all([...n.map(a=>this.callWebAdminApi("onLdapAliasChange-delete",`${this.webadminUrl}/address/aliases/${e}/sources/${a}`,"DELETE",t,null,{mail:e,alias:a,action:"delete"})),...o.map(async a=>{await this.dropStaleForwards(t,a,[]),await this.callWebAdminApi("onLdapAliasChange-add",`${this.webadminUrl}/address/aliases/${e}/sources/${a}`,"PUT",t,null,{mail:e,alias:a,action:"add"},p)})])},onLdapQuotaChange:async(t,e,a,i)=>{if(await this.isGroupDn(t))return;const s=i>0;return this.callWebAdminApi("onLdapQuotaChange",`${this.webadminUrl}/quota/users/${e}/size`,s?"PUT":"DELETE",t,s?i.toString():null,{oldQuota:a,newQuota:i},p)},onLdapForwardChange:async(t,e,a,i)=>{if(!this.config.james_sync_forwards)return;if(await this.isGroupDn(t))return;const s=this.extractMailDomain(e);if(!s)return void this.logger.error(`Cannot extract domain from mail ${e} for forward management`);const r=a.filter(t=>!i.includes(t)),n=i.filter(t=>!a.includes(t));await Promise.all([...r.map(a=>this.callWebAdminApi("onLdapForwardChange-delete",`${this.webadminUrl}/address/forwards/${e}/targets/${a}`,"DELETE",t,null,{mail:e,forward:a,domain:s,action:"delete"})),...n.map(a=>this.callWebAdminApi("onLdapForwardChange-add",`${this.webadminUrl}/address/forwards/${e}/targets/${a}`,"PUT",t,null,{mail:e,forward:a,domain:s,action:"add"},p))])},onLdapChange:async(t,e)=>{this.config.delegation_attribute&&e[this.config.delegation_attribute]&&!await this.isGroupDn(t)&&await this._handleDelegationChange(t,e)},onLdapDisplayNameChange:async t=>{const e=[this.mailAttr,this.displayNameAttr,"cn","givenName","sn","objectClass"],a=await this.ldapGetAttributes(t,e);if(!a)return void this.logger.warn(`Cannot update James identity: entry not found for ${t}`);if(isGroup(a.objectClass))return;const i=this.attributeToString(a[this.mailAttr]);if(!i)return void this.logger.warn(`Cannot update James identity: no mail found for ${t}`);const s=this.getDisplayNameFromAttributes(a);if(s)return this.updateJamesIdentity(t,i,s);this.logger.warn(`Cannot update James identity: no display name found for ${t}`)},onLdapEntryChange:async(t,e,a)=>{e&&a&&!e[this.mailAttr]&&a[this.mailAttr]&&await this.provisionAccount(t,a),await this.followTeamMailbox(e,a),await this.followDomains(e,a)}},this.domainQueue=new Map,this.teamMailboxQueue=new Map,this.identityQueue=new Map,this.quotaAttr=this.config.quota_attribute,this.aliasAttr=this.config.alias_attribute||"mailAlternateAddress",this.mailboxTypeAttr=this.config.james_mailbox_type_attribute||"twakeMailboxType",this.forwardAttr=this.config.forward_attribute||"mailForwardingAddress",this.initDelay="number"==typeof this.config.james_init_delay?this.config.james_init_delay:1e3,this.retryAttempts="number"==typeof this.config.james_retry_attempts?Math.max(1,this.config.james_retry_attempts):5,this.retryDelay="number"==typeof this.config.james_retry_delay?Math.max(0,this.config.james_retry_delay):1e3}api(t){const e=this.config.api_prefix||"/api";t.get(`${e}/v1/users/:user/quota-usage`,i((t,e)=>this.getUserQuota(t,e))),this.logger.info(`James API registered at ${e}/v1/users/:user/quota-usage`)}async getUserQuota(e,a){if(!u(e,a))return;const i=e.params.user;try{const r=s(i),n=await this.server.ldap.system.search({scope:"sub",filter:`(uid=${r})`,paged:!1,attributes:[this.mailAttr]},this.config.ldap_base||"");let o;for(const t of n.searchEntries??[])if(await this.server.ldap.readableBy(String(t.dn),e)){o=t;break}if(!o)return void a.status(404).json({error:`User ${i} not found`});const l=o[this.mailAttr];if(!l)return void a.status(400).json({error:`User ${i} has no mail attribute`});const d=Array.isArray(l)?String(l[0]):String(l),m=`${this.webadminUrl}/quota/users/${d}`,h=await this.requestLimit(()=>t(m,{method:"GET",headers:this.createHeaders()}));if(!h.ok)return void(404===h.status?a.status(404).json({error:`Quota not found for user ${i}`}):a.status(502).json({error:`Failed to retrieve quota from James: ${h.statusText}`}));const u=await h.json();a.json(u)}catch(t){this.logger.error({plugin:this.name,event:"getUserQuota",username:i,error:t instanceof Error?t.message:String(t)}),a.status(500).json({error:"Internal server error while retrieving quota"})}}domainAttribute(t){let e,a=-1;for(const{base:i,attribute:s}of c(this.server))try{if(sameDn(t,i)||!r(t,i))continue;const o=n(i).length;o>a&&([e,a]=[s,o])}catch{}return e}domainsOf(t){const e=t?.dn&&this.domainAttribute(String(t.dn));return e?g(valuesOf(t,e)):[]}afterLoad(){if(!this.server.loadedPlugins.trash)return;const t=String(this.config.trash_watched_bases??"").split(";").map(t=>t.trim()).filter(Boolean);for(const{base:e}of c(this.server)){const a=t.length?t.find(t=>{try{return r(e,t)||r(t,e)}catch{return!1}}):"every branch";a&&this.logger.warn(`James: the trash watches the mail domains of ${e} (--trash-watched-bases: ${a}): a domain deleted there may skip the check that it is still in use; leave the domain branch out of the trash, see docs/usage/plugins/integrations/james-mail.md#trash`)}}async followDomains(t,e,a=!1){const i=this.domainsOf(t),s=this.domainsOf(e);if(!i.length&&!s.length)return;const r=String((e??t).dn),call=(t,e)=>this.callWebAdminApi("PUT"===e?"domain:create":"domain:delete",`${this.webadminUrl}/domains/${t}`,e,r,null,{domain:t},f);await Promise.all([...s.filter(t=>!i.includes(t)).map(t=>this.inDomainOrder(t,()=>call(t,"PUT"))),...i.filter(t=>!s.includes(t)).map(t=>this.inDomainOrder(t,async()=>{if(await this.domainDeclaredElsewhere(t,r))return;const e=a?await this.addressAt(t):null;e?this.logger.error({plugin:this.name,event:"domain:delete",dn:r,domain:t,result:"skipped",error:`${e} still has an address at ${t}: the domain is kept in James`}):await call(t,"DELETE")}))])}async followMove(t,e){const a=[this.domainAttribute(t),this.domainAttribute(e)].filter(t=>Boolean(t)).filter((t,e,a)=>a.findIndex(e=>sameAttr(e,t))===e);if(!a.length)return;const i=await this.ldapGetAttributes(e,a);i&&await this.followDomains({...i,dn:t},{...i,dn:e},!0)}async inDomainOrder(t,e){const a=(this.domainQueue.get(t)??Promise.resolve()).then(e,e);this.domainQueue.set(t,a);try{await a}finally{this.domainQueue.get(t)===a&&this.domainQueue.delete(t)}}async firstEntry(t,e,a,i){try{const s=await this.server.ldap.system.search({paged:{pageSize:500},scope:"sub",filter:e,attributes:a},t);for await(const t of s)for(const e of t.searchEntries||[])if(i(e))return e;return null}catch(t){if(32===m(t))return null;throw t}}async domainDeclaredElsewhere(t,e){try{for(const{base:a,attribute:i}of c(this.server)){if(await this.firstEntry(a,`(${i}=${s(t)})`,["1.1"],t=>{const a=String(t.dn),s=this.domainAttribute(a);return!sameDn(a,e)&&!!s&&sameAttr(s,i)}))return!0}return!1}catch(a){return this.logger.error({plugin:this.name,event:"domain:delete",dn:e,domain:t,result:"skipped",error:a instanceof Error?a.message:String(a)}),!0}}async guardDomainRemoval(t,e){const a=this.domainAttribute(t);if(!a)return;const i=await this.ldapGetAttributes(t,[a]),s=g(valuesOf(i??void 0,a));if(!s.length)return;const r=await this.domainLinkHolder(t,String(i.dn??t));if(r)throw this.logger.warn(`${e} of ${t} refused: organization ${r} links it`),new d(`Cannot ${e} ${t}: an organization still links mail domain ${s.join(", ")}`);for(const a of s)await this.assertNoAddressAt(t,a,`Cannot ${e} ${t}`)}async guardDomainMove(t,e){const a=this.domainAttribute(t);if(!a)return;const i=this.domainAttribute(e);if(!i)return this.guardDomainRemoval(t,"move");const s=await this.ldapGetAttributes(t,sameAttr(a,i)?[a]:[a,i])??void 0,r=g(valuesOf(s,i));for(const e of g(valuesOf(s,a)))r.includes(e)||await this.assertNoAddressAt(t,e,`Cannot move ${t}`)}async guardDomainChange(t,e){const a=this.domainAttribute(t);if(!a||!((t,e)=>[t.add,t.replace].some(t=>t&&Object.keys(t).some(t=>sameAttr(t,e)))||(Array.isArray(t.delete)?t.delete.some(t=>sameAttr(t,e)):Object.keys(t.delete??{}).some(t=>sameAttr(t,e))))(e,a))return;const i=await this.ldapGetAttributes(t,[a]);if(!i)return;const s=g(valuesAfter(i,e,a,t=>t.toLowerCase()));for(const e of g(valuesOf(i,a)))s.includes(e)||await this.assertNoAddressAt(t,e,`Cannot remove mail domain ${e} from ${t}`)}domainLinkAttributes(){const t=this.server.loadedPlugins.ldapOrganizations,e=this.server.loadedPlugins.ldapFlatGeneric,i=[t?.schema,...(e?.instances||[]).map(t=>t.schema)].map(t=>a(t,"domainLink"));i.push(this.config.enterprise_domain_link_attribute);const s=[];for(const t of i)t&&!s.some(e=>sameAttr(e,t))&&s.push(t);return s}async domainLinkHolder(t,e){const a=this.domainLinkAttributes();if(!a.length)return null;const i=[...new Set([t,e,o(t)])],r=`(|${a.flatMap(t=>i.map(e=>`(${t}=${s(e)})`)).join("")})`,n=await this.firstEntry(this.config.ldap_base||"",r,a,e=>a.some(a=>(valuesOf(e,a)??[]).some(e=>sameDn(e,t))));return n?String(n.dn):null}async addressAt(t){const e=[this.mailAttr,this.aliasAttr],atDomain=e=>this.extractMailDomain(this.normalizeAlias(e.trim()))?.toLowerCase()===t,a=await this.firstEntry(this.config.ldap_base||"",`(|${e.map(t=>`(${t}=*)`).join("")})`,e,t=>e.some(e=>(valuesOf(t,e)??[]).some(atDomain)));return a?String(a.dn):null}async assertNoAddressAt(t,e,a){if(await this.domainDeclaredElsewhere(e,t))return;const i=await this.addressAt(e);if(i)throw this.logger.warn(`${a}: ${i} still has an address at ${e}`),new d(`${a}: addresses at ${e} still exist, and James would refuse their mail`)}teamMailboxOf(t){if(!t||"teamMailbox"!==this.getMailboxType(t))return null;const e=this.attributeToString(t[this.mailAttr]);if(!e)return null;const a=String(t.dn),i=this.config.james_mailing_list_branch||[];if(i.some(t=>a===t||a.endsWith(","+t)))return{mail:e,refused:`Team mailbox cannot be in mailing list branches: ${i.join(", ")}`};const s=e.lastIndexOf("@");return/^[#a-zA-Z0-9_-]{1,255}$/.test(e.slice(0,s))&&this.extractMailDomain(e)?{mail:e,refused:null}:{mail:e,refused:"A team mailbox address is a name of letters, digits, #, - and _ at a domain"}}async provisionAccount(t,e){const a=e[this.mailAttr],i=e[this.quotaAttr],s=e[this.aliasAttr];if(!a||isGroup(e.objectClass))return;const r=Array.isArray(a)?String(a[0]):String(a);if(this.initDelay>0&&await new Promise(t=>setTimeout(t,this.initDelay)),i){const e=Array.isArray(i)?Number(i[0]):Number(i);!isNaN(e)&&e>0&&await this.callWebAdminApi("ldapadddone:quota",`${this.webadminUrl}/quota/users/${r}/size`,"PUT",t,e.toString(),{mail:r,quota:e},p)}const n=this.getAliases(s);await Promise.all([this.dropStaleForwards(t,r,this.ownForwards(e[this.forwardAttr])),...n.map(e=>this.dropStaleForwards(t,e,[]))]),n.length>0&&await Promise.all(n.map(e=>this.callWebAdminApi("ldapadddone:alias",`${this.webadminUrl}/address/aliases/${r}/sources/${e}`,"PUT",t,null,{mail:r,alias:e},p)));const o=this.getDisplayNameFromAttributes(e);o&&await this.updateJamesIdentity(t,r,o)}async followTeamMailbox(t,e){const a=this.teamMailboxOf(t),i=this.teamMailboxOf(e);if(!a&&!i)return;const s=String((e??t).dn);i?.refused&&i.refused!==a?.refused&&this.logger.error({plugin:this.name,event:"teamMailbox",dn:s,mail:i.mail,error:i.refused});const r=a&&!a.refused?a.mail:null,n=i&&!i.refused?i.mail:null;if(!r&&!n)return;const o=memberKey(s),follow=async()=>{if(r&&n&&r.toLowerCase()===n.toLowerCase()){const a=new Map(members(t).map(t=>[memberKey(t),t])),i=[];for(const t of members(e))a.delete(memberKey(t))||i.push(t);return void await Promise.all([this.teamMailboxMembers(s,n,"PUT",i),this.teamMailboxMembers(s,n,"DELETE",[...a.values()])])}r&&await this.teamMailboxMembers(s,r,"DELETE",members(t)),n&&await this.createTeamMailbox(s,e,n)},l=(this.teamMailboxQueue.get(o)??Promise.resolve()).then(follow,follow);this.teamMailboxQueue.set(o,l);try{await l}finally{this.teamMailboxQueue.get(o)===l&&this.teamMailboxQueue.delete(o)}}getConfigApiData(){return{teamMailboxAnyDomain:Boolean(this.config.james_team_mailbox_any_domain)}}teamMailboxDomain(t,e){if(this.config.james_team_mailbox_any_domain||"teamMailbox"!==this.getMailboxType({[this.mailboxTypeAttr]:e}))return null;const a=this.attributeToString(t);return a&&this.extractMailDomain(a)?.toLowerCase()||null}async guardTeamMailboxChange(t,e){if(this.config.james_team_mailbox_any_domain)return;const a=[this.mailAttr,this.mailboxTypeAttr,"member"];if(![e.add,e.replace].some(t=>t&&Object.keys(t).some(t=>a.some(e=>sameAttr(t,e)))))return;const i=await this.ldapGetAttributes(t,a);if(!i)return;const after=(t,a=t=>t.toLowerCase())=>valuesAfter(i,e,t,a),s=this.teamMailboxDomain(after(this.mailAttr),after(this.mailboxTypeAttr));if(!s)return;const r=new Set((valuesOf(i,"member")??[]).map(memberKey)),n=after("member",memberKey),o=n.filter(t=>!r.has(memberKey(t))),l=s!==this.teamMailboxDomain(valuesOf(i,this.mailAttr),valuesOf(i,this.mailboxTypeAttr));await this.assertMembersAt(t,s,o,l?n.filter(t=>r.has(memberKey(t))):[])}async assertMembersAt(t,e,a,i=[]){const foreign=async t=>(await this.getMemberEmails(t)).filter(t=>this.extractMailDomain(t)?.toLowerCase()!==e),[s,r]=await Promise.all([foreign(a),foreign(i)]);if(0===s.length&&0===r.length)return;const n=s.length?`${s.slice(0,5).join(", ")}${s.length>5?", …":""} `+(s.length>1?"are":"is")+" not":"",o=r.length?"some of the members it holds are not":"";throw new d(`The members of team mailbox ${t} must have an address at ${e}: `+[n,o].filter(Boolean).join(", and "))}teamMailboxUrl(t){const e=t.toLowerCase(),a=e.lastIndexOf("@");return`${this.webadminUrl}/domains/${e.slice(a+1)}/team-mailboxes/${encodeURIComponent(e.slice(0,a))}`}async teamMailboxMembers(t,e,a,i){if(!i.length)return;const s=this.teamMailboxUrl(e),r=await this.getMemberEmails(i);await Promise.all(r.map(i=>this.callWebAdminApi("teamMailbox:"+("PUT"===a?"addMember":"removeMember"),`${s}/members/${i}`,a,t,null,{teamMailbox:e,memberMail:i})))}getDisplayNameFromAttributes(t){const e=this.attributeToString(t[this.displayNameAttr]);if(e)return e;const a=this.attributeToString(t.cn);if(a)return a;const i=this.attributeToString(t.givenName),s=this.attributeToString(t.sn);if(i||s){const t=[];return i&&t.push(i),s&&t.push(s),t.join(" ")}const r=this.attributeToString(t[this.mailAttr]);return r||null}async isGroupDn(t){const e=await this.ldapGetAttributes(t,["objectClass"]);return isGroup(e?.objectClass)}async getMailFromDN(t){const e=await this.ldapGetAttributes(t,[this.mailAttr]);return e?this.attributeToString(e[this.mailAttr]):null}async getDisplayNameFromDN(t){const e=[this.displayNameAttr,"cn","givenName","sn",this.mailAttr],a=await this.ldapGetAttributes(t,e);return a?this.getDisplayNameFromAttributes(a):null}async generateSignature(t){const e=this.config.james_signature_template;if(!e)return null;const a=await this.ldapGetAttributes(t);if(!a)return null;let i=e;return i=i.replace(/\{(\w+)\}/g,(t,e)=>"string"==typeof e&&a&&"object"==typeof a&&Object.prototype.hasOwnProperty.call(a,e)&&this.attributeToString(a[e])||""),i}async updateJamesIdentity(t,e,a,i={}){const s=(this.identityQueue.get(e)??Promise.resolve()).then(()=>this.doUpdateJamesIdentity(t,e,a,i));this.identityQueue.set(e,s);try{await s}finally{this.identityQueue.get(e)===s&&this.identityQueue.delete(e)}}async doUpdateJamesIdentity(e,a,i,s){const r={plugin:this.name,event:"onLdapDisplayNameChange",result:"error",dn:e,mail:a,displayName:i};for(let n=1;;n++){try{const o=`${this.webadminUrl}/users/${a}/identities`,l=await this.requestLimit(()=>t(`${o}?default=true`,{method:"GET",headers:this.createHeaders()}));let d;if(l.ok)d=pickDefaultIdentity(await l.json());else if(404!==l.status){const t="get_default_identity";if(this.shouldRetry(l.status,p,n)){await this.warnAndWait(r,t,n,l);continue}return void this.logger.error({...r,step:t,...n>1?{attempts:n}:{},http_status:l.status,http_status_text:l.statusText})}const m=await this.generateSignature(e),h=d?.id?{id:d.id,email:d.email,name:i}:{email:a,name:i,sortOrder:0};m?h.htmlSignature=m:Object.assign(h,s);const u=d?.id?"update_identity":"create_identity",c=await this.requestLimit(()=>t(d?.id?`${o}/${d.id}`:o,{method:d?.id?"PUT":"POST",headers:this.createHeaders("application/json"),body:JSON.stringify(h)}));if(c.ok)this.logger.info({...r,step:u,result:"success",http_status:c.status});else{if(this.shouldRetry(c.status,p,n)){await this.warnAndWait(r,u,n,c);continue}this.logger.error({...r,step:u,...n>1?{attempts:n}:{},http_status:c.status,http_status_text:c.statusText})}}catch(t){this.logger.error({...r,error:`${t}`})}return}}async defaultSignatures(e){try{const a=await this.requestLimit(()=>t(`${this.webadminUrl}/users/${e}/identities?default=true`,{method:"GET",headers:this.createHeaders()}));if(!a.ok)return{};const i=pickDefaultIdentity(await a.json()),s={};return i?.htmlSignature&&(s.htmlSignature=i.htmlSignature),i?.textSignature&&(s.textSignature=i.textSignature),s}catch(t){return this.logger.debug(`Could not read the identities of ${e}: ${t}`),{}}}ownForwards(t){return this.config.james_sync_forwards?this.getAliases(t):[]}async dropStaleForwards(e,a,i){let s;try{const r=await this.requestLimit(()=>t(`${this.webadminUrl}/address/forwards/${a}`,{method:"GET",headers:this.createHeaders()}));if(!r.ok)return void(404!==r.status&&this.logger.warn({plugin:this.name,event:"dropStaleForward",result:"error",dn:e,address:a,step:"list_forwards",http_status:r.status,http_status_text:r.statusText}));const n=i.map(t=>t.toLowerCase());s=(await r.json()).map(t=>t.mailAddress).filter(t=>t&&!n.includes(t.toLowerCase()))}catch(t){return void this.logger.debug(`Could not read the forwards of ${a}: ${t}`)}await Promise.all(s.map(t=>this.callWebAdminApi("dropStaleForward",`${this.webadminUrl}/address/forwards/${a}/targets/${t}`,"DELETE",e,null,{address:a,forward:t})))}async warnAndWait(t,e,a,i){this.logger.warn({...t,step:e,result:"retry",attempt:a,http_status:i.status,http_status_text:i.statusText}),await this.waitBeforeRetry(a)}async deleteUserData(e,a){const i={plugin:this.name,event:"deleteUserData",mail:e,fromStep:a};try{const s=new URL(`${this.webadminUrl}/users/${e}`);s.searchParams.set("action","deleteData"),a&&s.searchParams.set("fromStep",a);const r=await this.requestLimit(()=>t(s.toString(),{method:"POST",headers:this.createHeaders()}));if(!r.ok)return this.logger.error({...i,http_status:r.status,http_status_text:r.statusText}),null;const n=await r.json();return this.logger.info({...i,http_status:r.status,taskId:n.taskId}),n}catch(t){return this.logger.error({...i,error:`${t}`}),null}}getMailboxType(t){const e=t[this.mailboxTypeAttr];if(!e)return null;const a=Array.isArray(e)?String(e[0]):String(e),i=/^\s*cn\s*=\s*([^,]+)/i.exec(a);if(!i)return null;return{group:"group",mailinglist:"mailingList",teammailbox:"teamMailbox"}[i[1].trim().toLowerCase()]??null}async createTeamMailbox(t,e,a){const i=this.teamMailboxUrl(a);await this.callWebAdminApi("teamMailbox:create",i,"PUT",t,null,{teamMailbox:a}),await this.teamMailboxMembers(t,a,"PUT",members(e))}async getMemberEmails(t){const e=t.filter(t=>!l(t,this.config.group_dummy_user)).map(t=>this.server.ldap.queryLimit(async()=>{const e=await this.ldapGetAttributes(t,[this.mailAttr]);return e?this.attributeToString(e[this.mailAttr]):null}));return(await Promise.all(e)).filter(t=>null!==t)}async getGroupMail(t){const e=await this.ldapGetAttributes(t,["mail"]);return e?this.attributeToString(e.mail):null}async getGroupMailboxType(t){try{const e=await this.server.ldap.system.search({paged:!1,scope:"base",attributes:[this.mailboxTypeAttr]},t);if(e.searchEntries&&e.searchEntries.length>0)return this.getMailboxType(e.searchEntries[0])}catch(e){const a=e instanceof Error?e.message:String(e);this.logger.debug(`Could not get mailbox type for group ${t}: ${a}`)}return null}async _handleDelegationChange(t,e){const a=await this.ldapGetAttributes(t,[this.mailAttr]);if(!a)return void this.logger.warn({plugin:this.name,event:"onLdapChange",dn:t,message:"Could not find user entry to get mail attribute"});const i=a[this.mailAttr];if(!i||"string"!=typeof i)return void this.logger.warn({plugin:this.name,event:"onLdapChange",dn:t,message:"User has no mail attribute, cannot manage delegation"});const s=this.config.delegation_attribute;if(!s)return;const[r,n]=e[s]||[],o=this._normalizeToArray(r),l=this._normalizeToArray(n),d=l.filter(t=>!o.includes(t)),m=o.filter(t=>!l.includes(t)),h=d.map(t=>this.server.ldap.queryLimit(async()=>{const e=await this._getDelegateEmail(t);return{dn:t,email:e}})),u=m.map(t=>this.server.ldap.queryLimit(async()=>{const e=await this._getDelegateEmail(t);return{dn:t,email:e}})),[c,g]=await Promise.all([Promise.all(h),Promise.all(u)]),f=[];for(const{dn:e,email:a}of c)a&&f.push(this.callWebAdminApi("onLdapChange:addDelegation",`${this.webadminUrl}/users/${i}/authorizedUsers/${a}`,"PUT",t,null,{userMail:i,delegateEmail:a,delegateDN:e,action:"add"},p));for(const{dn:e,email:a}of g)a&&f.push(this.callWebAdminApi("onLdapChange:removeDelegation",`${this.webadminUrl}/users/${i}/authorizedUsers/${a}`,"DELETE",t,null,{userMail:i,delegateEmail:a,delegateDN:e,action:"remove"}));await Promise.all(f)}async _getDelegateEmail(t){const e=await this.ldapGetAttributes(t,[this.mailAttr]);return e?this.attributeToString(e[this.mailAttr]):(this.logger.warn({plugin:this.name,event:"getDelegateEmail",dn:t,message:"Could not resolve delegate DN to email"}),null)}_normalizeToArray(t){return t?Array.isArray(t)?t:[t]:[]}}export{James as default};
1
+ import{TwakePlugin as t}from"../../abstract/twakePlugin.js";import"node-fetch";import"../../lib/metrics.js";import{roleAttribute as e}from"../../config/schema.js";import{asyncHandler as a,isDnInBranch as i,parseDn as s,escapeLdapFilter as r,normalizeDn as n,isDummyMemberDn as o}from"../../lib/utils.js";import{ConflictError as l}from"../../lib/errors.js";import{extractLdapCode as d}from"../../lib/ldapCodes.js";import{currentOperation as m}from"../../lib/operation.js";import{wantJson as h}from"../../lib/expressFormatedResponses.js";import{domainBranches as u,mailDomains as c}from"../../lib/mailDomains.js";const g=[400,404],p=[500,502,503,504],pickDefaultIdentity=t=>(Array.isArray(t)?t:[t]).filter(t=>t&&t.id&&!1!==t.mayDelete).sort((t,e)=>(t.sortOrder??0)-(e.sortOrder??0))[0],members=t=>valuesOf(t??void 0,"member")??[],sameAttr=(t,e)=>t.split(";")[0].toLowerCase()===e.split(";")[0].toLowerCase(),valuesOf=(t,e)=>{if(!t)return;const a=Object.keys(t).filter(t=>sameAttr(t,e));return a.length?a.flatMap(e=>{return null==(a=t[e])?[]:(Array.isArray(a)?a:[a]).map(String);var a}):void 0},valuesAfter=(t,e,a,i)=>{let s=valuesOf(t,a)??[];const r=valuesOf(e.add,a);r&&(s=[...s,...r]);const n=valuesOf(e.replace,a);n&&(s=n);const o=e.delete;if(Array.isArray(o))o.some(t=>sameAttr(t,a))&&(s=[]);else{const t=valuesOf(o,a);if(t){const e=new Set(t.map(i));s=e.size?s.filter(t=>!e.has(i(t))):[]}}return s},memberKey=t=>{try{return n(t)}catch{return t.toLowerCase()}},f=["groupofnames","groupofuniquenames","groupofurls"],isGroup=t=>(Array.isArray(t)?t:t?[t]:[]).some(t=>f.includes(String(t).toLowerCase())),sameDn=(t,e)=>{try{return n(t)===n(e)}catch{return!1}};class James extends t{health(){return this.webadminHealth()}constructor(t){super(t,"james_webadmin_url","james_webadmin_token","james_concurrency"),this.name="james",this.roles=["consistency","api","configurable"],this.dependencies={onLdapChange:"core/ldap/onChange",ldapGroups:"core/ldap/groups"},this.hooks={ldapaddrequest:async([t,e,a])=>{const i=this.teamMailboxDomain(valuesOf(e,this.mailAttr),valuesOf(e,this.mailboxTypeAttr));return i&&await this.assertMembersAt(t,i,valuesOf(e,"member")??[]),void 0!==a?[t,e,a]:[t,e]},ldapmodifyrequest:async([t,e,a,i])=>(await this.guardTeamMailboxChange(t,e),await this.guardDomainChange(t,e),[t,e,a,i]),ldapdeleterequest:async([t,e])=>{for(const e of Array.isArray(t)?t:[t])e&&await this.guardDomainRemoval(e,"delete");return[t,e]},ldaprenamerequest:async([t,e,a])=>(await this.guardDomainMove(t,e),[t,e,a]),ldaprenamedone:async([t,e])=>{void 0===m()&&await this.followMove(t,e)},ldapadddone:([t,e])=>this.provisionAccount(t,e),onLdapMailChange:async(t,e,a)=>{const i=e?Array.isArray(e)?String(e[0]):String(e):null,s=a?Array.isArray(a)?String(a[0]):String(a):null;if(!i)return void this.logger.debug(`Skipping mail rename for ${t}: oldmail is empty (mail attribute was added, not changed)`);if(!s)return void this.logger.debug(`Skipping mail rename for ${t}: newmail is empty (mail attribute was deleted, not changed)`);if(await this.isGroupDn(t))return;const r=await this.defaultSignatures(i),n=await this.ldapGetAttributes(t,[this.forwardAttr,this.displayNameAttr,"cn","givenName","sn",this.mailAttr]);await this.dropStaleForwards(t,s,this.ownForwards(n?.[this.forwardAttr])),await this.callWebAdminApi("onLdapMailChange",`${this.webadminUrl}/users/${i}/rename/${s}?action=rename&force`,"POST",t,null,{oldmail:i,newmail:s},g);try{const e=await this.server.ldap.system.search({paged:!1,scope:"base",attributes:[this.aliasAttr]},t);if(e.searchEntries&&e.searchEntries.length>0){const a=this.getAliases(e.searchEntries[0][this.aliasAttr]);a.length>0&&await Promise.all([...a.map(e=>this.callWebAdminApi("onLdapMailChange-delete",`${this.webadminUrl}/address/aliases/${i}/sources/${e}`,"DELETE",t,null,{oldmail:i,alias:e})),...a.map(e=>this.callWebAdminApi("onLdapMailChange-create",`${this.webadminUrl}/address/aliases/${s}/sources/${e}`,"PUT",t,null,{newmail:s,alias:e},g))])}}catch(t){this.logger.debug("Could not fetch aliases for mail change:",t)}const o=n&&this.getDisplayNameFromAttributes(n);o&&await this.updateJamesIdentity(t,s,o,r)},onLdapAliasChange:async(t,e,a,i)=>{if(await this.isGroupDn(t))return;const s=a.map(t=>this.normalizeAlias(t)),r=i.map(t=>this.normalizeAlias(t)),n=s.filter(t=>!r.includes(t)),o=r.filter(t=>!s.includes(t));await Promise.all([...n.map(a=>this.callWebAdminApi("onLdapAliasChange-delete",`${this.webadminUrl}/address/aliases/${e}/sources/${a}`,"DELETE",t,null,{mail:e,alias:a,action:"delete"})),...o.map(async a=>{await this.dropStaleForwards(t,a,[]),await this.callWebAdminApi("onLdapAliasChange-add",`${this.webadminUrl}/address/aliases/${e}/sources/${a}`,"PUT",t,null,{mail:e,alias:a,action:"add"},g)})])},onLdapQuotaChange:async(t,e,a,i)=>{if(await this.isGroupDn(t))return;const s=i>0;await this.callWebAdminApi("onLdapQuotaChange",`${this.webadminUrl}/quota/users/${e}/size`,s?"PUT":"DELETE",t,s?i.toString():null,{oldQuota:a,newQuota:i},g)},onLdapForwardChange:async(t,e,a,i)=>{if(!this.config.james_sync_forwards)return;if(await this.isGroupDn(t))return;const s=this.extractMailDomain(e);if(!s)return void this.logger.error(`Cannot extract domain from mail ${e} for forward management`);const r=a.filter(t=>!i.includes(t)),n=i.filter(t=>!a.includes(t));await Promise.all([...r.map(a=>this.callWebAdminApi("onLdapForwardChange-delete",`${this.webadminUrl}/address/forwards/${e}/targets/${a}`,"DELETE",t,null,{mail:e,forward:a,domain:s,action:"delete"})),...n.map(a=>this.callWebAdminApi("onLdapForwardChange-add",`${this.webadminUrl}/address/forwards/${e}/targets/${a}`,"PUT",t,null,{mail:e,forward:a,domain:s,action:"add"},g))])},onLdapChange:async(t,e)=>{this.config.delegation_attribute&&e[this.config.delegation_attribute]&&!await this.isGroupDn(t)&&await this._handleDelegationChange(t,e)},onLdapDisplayNameChange:async t=>{const e=[this.mailAttr,this.displayNameAttr,"cn","givenName","sn","objectClass"],a=await this.ldapGetAttributes(t,e);if(!a)return void this.logger.warn(`Cannot update James identity: entry not found for ${t}`);if(isGroup(a.objectClass))return;const i=this.attributeToString(a[this.mailAttr]);if(!i)return void this.logger.warn(`Cannot update James identity: no mail found for ${t}`);const s=this.getDisplayNameFromAttributes(a);if(s)return this.updateJamesIdentity(t,i,s);this.logger.warn(`Cannot update James identity: no display name found for ${t}`)},onLdapEntryChange:async(t,e,a)=>{e&&a&&!e[this.mailAttr]&&a[this.mailAttr]&&await this.provisionAccount(t,a),await this.followTeamMailbox(e,a),await this.followDomains(e,a)}},this.domainQueue=new Map,this.teamMailboxQueue=new Map,this.identityQueue=new Map,this.quotaAttr=this.config.quota_attribute,this.aliasAttr=this.config.alias_attribute||"mailAlternateAddress",this.mailboxTypeAttr=this.config.james_mailbox_type_attribute||"twakeMailboxType",this.forwardAttr=this.config.forward_attribute||"mailForwardingAddress",this.initDelay="number"==typeof this.config.james_init_delay?this.config.james_init_delay:1e3,this.retryAttempts="number"==typeof this.config.james_retry_attempts?Math.max(1,this.config.james_retry_attempts):5,this.retryDelay="number"==typeof this.config.james_retry_delay?Math.max(0,this.config.james_retry_delay):1e3}api(t){const e=this.config.api_prefix||"/api";t.get(`${e}/v1/users/:user/quota-usage`,a((t,e)=>this.getUserQuota(t,e))),this.logger.info(`James API registered at ${e}/v1/users/:user/quota-usage`)}async getUserQuota(t,e){if(!h(t,e))return;const a=t.params.user;try{const i=await this.findReadableUser(a,t,[this.mailAttr]);if(!i)return void e.status(404).json({error:`User ${a} not found`});const s=i[this.mailAttr];if(!s)return void e.status(400).json({error:`User ${a} has no mail attribute`});const r=Array.isArray(s)?String(s[0]):String(s),n=`${this.webadminUrl}/quota/users/${r}`,o=await this.requestLimit(()=>this.fetch(n,{method:"GET",headers:this.createHeaders()}));if(!o.ok)return void(404===o.status?e.status(404).json({error:`Quota not found for user ${a}`}):e.status(502).json({error:`Failed to retrieve quota from James: ${o.statusText}`}));const l=await o.json();e.json(l)}catch(t){this.logger.error({plugin:this.name,event:"getUserQuota",username:a,error:t instanceof Error?t.message:String(t)}),e.status(500).json({error:"Internal server error while retrieving quota"})}}domainAttribute(t){let e,a=-1;for(const{base:r,attribute:n}of u(this.server))try{if(sameDn(t,r)||!i(t,r))continue;const o=s(r).length;o>a&&([e,a]=[n,o])}catch{}return e}domainsOf(t){const e=t?.dn&&this.domainAttribute(String(t.dn));return e?c(valuesOf(t,e)):[]}afterLoad(){if(!this.server.loadedPlugins.trash)return;const t=String(this.config.trash_watched_bases??"").split(";").map(t=>t.trim()).filter(Boolean);for(const{base:e}of u(this.server)){const a=t.length?t.find(t=>{try{return i(e,t)||i(t,e)}catch{return!1}}):"every branch";a&&this.logger.warn(`James: the trash watches the mail domains of ${e} (--trash-watched-bases: ${a}): a domain deleted there may skip the check that it is still in use; leave the domain branch out of the trash, see docs/usage/plugins/integrations/james-mail.md#trash`)}}async followDomains(t,e,a=!1){const i=this.domainsOf(t),s=this.domainsOf(e);if(!i.length&&!s.length)return;const r=String((e??t).dn),call=(t,e)=>this.callWebAdminApi("PUT"===e?"domain:create":"domain:delete",`${this.webadminUrl}/domains/${t}`,e,r,null,{domain:t},p);await Promise.all([...s.filter(t=>!i.includes(t)).map(t=>this.inDomainOrder(t,()=>call(t,"PUT"))),...i.filter(t=>!s.includes(t)).map(t=>this.inDomainOrder(t,async()=>{if(await this.domainDeclaredElsewhere(t,r))return;const e=a?await this.addressAt(t):null;e?this.logger.error({plugin:this.name,event:"domain:delete",dn:r,domain:t,result:"skipped",error:`${e} still has an address at ${t}: the domain is kept in James`}):await call(t,"DELETE")}))])}async followMove(t,e){const a=[this.domainAttribute(t),this.domainAttribute(e)].filter(t=>Boolean(t)).filter((t,e,a)=>a.findIndex(e=>sameAttr(e,t))===e);if(!a.length)return;const i=await this.ldapGetAttributes(e,a);i&&await this.followDomains({...i,dn:t},{...i,dn:e},!0)}async inDomainOrder(t,e){const run=async()=>{await e()},a=(this.domainQueue.get(t)??Promise.resolve()).then(run,run);this.domainQueue.set(t,a);try{await a}finally{this.domainQueue.get(t)===a&&this.domainQueue.delete(t)}}async firstEntry(t,e,a,i){try{const s=await this.server.ldap.system.search({paged:{pageSize:500},scope:"sub",filter:e,attributes:a},t);for await(const t of s)for(const e of t.searchEntries||[])if(i(e))return e;return null}catch(t){if(32===d(t))return null;throw t}}async domainDeclaredElsewhere(t,e){try{for(const{base:a,attribute:i}of u(this.server)){if(await this.firstEntry(a,`(${i}=${r(t)})`,["1.1"],t=>{const a=String(t.dn),s=this.domainAttribute(a);return!sameDn(a,e)&&!!s&&sameAttr(s,i)}))return!0}return!1}catch(a){return this.logger.error({plugin:this.name,event:"domain:delete",dn:e,domain:t,result:"skipped",error:a instanceof Error?a.message:String(a)}),!0}}async guardDomainRemoval(t,e){const a=this.domainAttribute(t);if(!a)return;const i=await this.ldapGetAttributes(t,[a]),s=c(valuesOf(i??void 0,a));if(!s.length)return;const r=await this.domainLinkHolder(t,String(i.dn??t));if(r)throw this.logger.warn(`${e} of ${t} refused: organization ${r} links it`),new l(`Cannot ${e} ${t}: an organization still links mail domain ${s.join(", ")}`);for(const a of s)await this.assertNoAddressAt(t,a,`Cannot ${e} ${t}`)}async guardDomainMove(t,e){const a=this.domainAttribute(t);if(!a)return;const i=this.domainAttribute(e);if(!i)return this.guardDomainRemoval(t,"move");const s=await this.ldapGetAttributes(t,sameAttr(a,i)?[a]:[a,i])??void 0,r=c(valuesOf(s,i));for(const e of c(valuesOf(s,a)))r.includes(e)||await this.assertNoAddressAt(t,e,`Cannot move ${t}`)}async guardDomainChange(t,e){const a=this.domainAttribute(t);if(!a||!((t,e)=>[t.add,t.replace].some(t=>t&&Object.keys(t).some(t=>sameAttr(t,e)))||(Array.isArray(t.delete)?t.delete.some(t=>sameAttr(t,e)):Object.keys(t.delete??{}).some(t=>sameAttr(t,e))))(e,a))return;const i=await this.ldapGetAttributes(t,[a]);if(!i)return;const s=c(valuesAfter(i,e,a,t=>t.toLowerCase()));for(const e of c(valuesOf(i,a)))s.includes(e)||await this.assertNoAddressAt(t,e,`Cannot remove mail domain ${e} from ${t}`)}domainLinkAttributes(){const t=this.server.loadedPlugins.ldapOrganizations,a=this.server.loadedPlugins.ldapFlatGeneric,i=[t?.schema,...(a?.instances||[]).map(t=>t.schema)].map(t=>e(t,"domainLink"));i.push(this.config.enterprise_domain_link_attribute);const s=[];for(const t of i)t&&!s.some(e=>sameAttr(e,t))&&s.push(t);return s}async domainLinkHolder(t,e){const a=this.domainLinkAttributes();if(!a.length)return null;const i=[...new Set([t,e,n(t)])],s=`(|${a.flatMap(t=>i.map(e=>`(${t}=${r(e)})`)).join("")})`,o=await this.firstEntry(this.config.ldap_base||"",s,a,e=>a.some(a=>(valuesOf(e,a)??[]).some(e=>sameDn(e,t))));return o?String(o.dn):null}async addressAt(t){const e=[this.mailAttr,this.aliasAttr],atDomain=e=>this.extractMailDomain(this.normalizeAlias(e.trim()))?.toLowerCase()===t,a=await this.firstEntry(this.config.ldap_base||"",`(|${e.map(t=>`(${t}=*)`).join("")})`,e,t=>e.some(e=>(valuesOf(t,e)??[]).some(atDomain)));return a?String(a.dn):null}async assertNoAddressAt(t,e,a){if(await this.domainDeclaredElsewhere(e,t))return;const i=await this.addressAt(e);if(i)throw this.logger.warn(`${a}: ${i} still has an address at ${e}`),new l(`${a}: addresses at ${e} still exist, and James would refuse their mail`)}teamMailboxOf(t){if(!t||"teamMailbox"!==this.getMailboxType(t))return null;const e=this.attributeToString(t[this.mailAttr]);if(!e)return null;const a=String(t.dn),i=this.config.james_mailing_list_branch||[];if(i.some(t=>a===t||a.endsWith(","+t)))return{mail:e,refused:`Team mailbox cannot be in mailing list branches: ${i.join(", ")}`};const s=e.lastIndexOf("@");return/^[#a-zA-Z0-9_-]{1,255}$/.test(e.slice(0,s))&&this.extractMailDomain(e)?{mail:e,refused:null}:{mail:e,refused:"A team mailbox address is a name of letters, digits, #, - and _ at a domain"}}async provisionAccount(t,e){const a=e[this.mailAttr],i=e[this.quotaAttr],s=e[this.aliasAttr];if(!a||isGroup(e.objectClass))return;const r=Array.isArray(a)?String(a[0]):String(a);if(this.initDelay>0&&await new Promise(t=>setTimeout(t,this.initDelay)),i){const e=Array.isArray(i)?Number(i[0]):Number(i);!isNaN(e)&&e>0&&await this.callWebAdminApi("ldapadddone:quota",`${this.webadminUrl}/quota/users/${r}/size`,"PUT",t,e.toString(),{mail:r,quota:e},g)}const n=this.getAliases(s);await Promise.all([this.dropStaleForwards(t,r,this.ownForwards(e[this.forwardAttr])),...n.map(e=>this.dropStaleForwards(t,e,[]))]),n.length>0&&await Promise.all(n.map(e=>this.callWebAdminApi("ldapadddone:alias",`${this.webadminUrl}/address/aliases/${r}/sources/${e}`,"PUT",t,null,{mail:r,alias:e},g)));const o=this.getDisplayNameFromAttributes(e);o&&await this.updateJamesIdentity(t,r,o)}async followTeamMailbox(t,e){const a=this.teamMailboxOf(t),i=this.teamMailboxOf(e);if(!a&&!i)return;const s=String((e??t).dn);i?.refused&&i.refused!==a?.refused&&this.logger.error({plugin:this.name,event:"teamMailbox",dn:s,mail:i.mail,error:i.refused});const r=a&&!a.refused?a.mail:null,n=i&&!i.refused?i.mail:null;if(!r&&!n)return;const o=memberKey(s),follow=async()=>{if(r&&n&&r.toLowerCase()===n.toLowerCase()){const a=new Map(members(t).map(t=>[memberKey(t),t])),i=[];for(const t of members(e))a.delete(memberKey(t))||i.push(t);return void await Promise.all([this.teamMailboxMembers(s,n,"PUT",i),this.teamMailboxMembers(s,n,"DELETE",[...a.values()])])}r&&await this.teamMailboxMembers(s,r,"DELETE",members(t)),n&&await this.createTeamMailbox(s,e,n)},l=(this.teamMailboxQueue.get(o)??Promise.resolve()).then(follow,follow);this.teamMailboxQueue.set(o,l);try{await l}finally{this.teamMailboxQueue.get(o)===l&&this.teamMailboxQueue.delete(o)}}getConfigApiData(){return{teamMailboxAnyDomain:Boolean(this.config.james_team_mailbox_any_domain)}}teamMailboxDomain(t,e){if(this.config.james_team_mailbox_any_domain||"teamMailbox"!==this.getMailboxType({[this.mailboxTypeAttr]:e}))return null;const a=this.attributeToString(t);return a&&this.extractMailDomain(a)?.toLowerCase()||null}async guardTeamMailboxChange(t,e){if(this.config.james_team_mailbox_any_domain)return;const a=[this.mailAttr,this.mailboxTypeAttr,"member"];if(![e.add,e.replace].some(t=>t&&Object.keys(t).some(t=>a.some(e=>sameAttr(t,e)))))return;const i=await this.ldapGetAttributes(t,a);if(!i)return;const after=(t,a=t=>t.toLowerCase())=>valuesAfter(i,e,t,a),s=this.teamMailboxDomain(after(this.mailAttr),after(this.mailboxTypeAttr));if(!s)return;const r=new Set((valuesOf(i,"member")??[]).map(memberKey)),n=after("member",memberKey),o=n.filter(t=>!r.has(memberKey(t))),l=s!==this.teamMailboxDomain(valuesOf(i,this.mailAttr),valuesOf(i,this.mailboxTypeAttr));await this.assertMembersAt(t,s,o,l?n.filter(t=>r.has(memberKey(t))):[])}async assertMembersAt(t,e,a,i=[]){const foreign=async t=>(await this.getMemberEmails(t)).filter(t=>this.extractMailDomain(t)?.toLowerCase()!==e),[s,r]=await Promise.all([foreign(a),foreign(i)]);if(0===s.length&&0===r.length)return;const n=s.length?`${s.slice(0,5).join(", ")}${s.length>5?", …":""} `+(s.length>1?"are":"is")+" not":"",o=r.length?"some of the members it holds are not":"";throw new l(`The members of team mailbox ${t} must have an address at ${e}: `+[n,o].filter(Boolean).join(", and "))}teamMailboxUrl(t){const e=t.toLowerCase(),a=e.lastIndexOf("@");return`${this.webadminUrl}/domains/${e.slice(a+1)}/team-mailboxes/${encodeURIComponent(e.slice(0,a))}`}async teamMailboxMembers(t,e,a,i){if(!i.length)return;const s=this.teamMailboxUrl(e),r=await this.getMemberEmails(i);await Promise.all(r.map(i=>this.callWebAdminApi("teamMailbox:"+("PUT"===a?"addMember":"removeMember"),`${s}/members/${i}`,a,t,null,{teamMailbox:e,memberMail:i})))}getDisplayNameFromAttributes(t){const e=this.attributeToString(t[this.displayNameAttr]);if(e)return e;const a=this.attributeToString(t.cn);if(a)return a;const i=this.attributeToString(t.givenName),s=this.attributeToString(t.sn);if(i||s){const t=[];return i&&t.push(i),s&&t.push(s),t.join(" ")}const r=this.attributeToString(t[this.mailAttr]);return r||null}async isGroupDn(t){const e=await this.ldapGetAttributes(t,["objectClass"]);return isGroup(e?.objectClass)}async getMailFromDN(t){const e=await this.ldapGetAttributes(t,[this.mailAttr]);return e?this.attributeToString(e[this.mailAttr]):null}async getDisplayNameFromDN(t){const e=[this.displayNameAttr,"cn","givenName","sn",this.mailAttr],a=await this.ldapGetAttributes(t,e);return a?this.getDisplayNameFromAttributes(a):null}async generateSignature(t){const e=this.config.james_signature_template;if(!e)return null;const a=await this.ldapGetAttributes(t);if(!a)return null;let i=e;return i=i.replace(/\{(\w+)\}/g,(t,e)=>"string"==typeof e&&a&&"object"==typeof a&&Object.prototype.hasOwnProperty.call(a,e)&&this.attributeToString(a[e])||""),i}async updateJamesIdentity(t,e,a,i={}){const s=(this.identityQueue.get(e)??Promise.resolve()).then(()=>this.doUpdateJamesIdentity(t,e,a,i));this.identityQueue.set(e,s);try{await s}finally{this.identityQueue.get(e)===s&&this.identityQueue.delete(e)}}async doUpdateJamesIdentity(t,e,a,i){const s={plugin:this.name,event:"onLdapDisplayNameChange",result:"error",dn:t,mail:e,displayName:a};for(let r=1;;r++){try{const n=`${this.webadminUrl}/users/${e}/identities`,o=await this.requestLimit(()=>this.fetch(`${n}?default=true`,{method:"GET",headers:this.createHeaders()}));let l;if(o.ok)l=pickDefaultIdentity(await o.json());else if(404!==o.status){const t="get_default_identity";if(this.shouldRetry(o.status,g,r)){await this.warnAndWait(s,t,r,o);continue}return void this.logger.error({...s,step:t,...r>1?{attempts:r}:{},http_status:o.status,http_status_text:o.statusText})}const d=await this.generateSignature(t),m=l?.id?{id:l.id,email:l.email,name:a}:{email:e,name:a,sortOrder:0};d?m.htmlSignature=d:Object.assign(m,i);const h=l?.id?"update_identity":"create_identity",u=await this.requestLimit(()=>this.fetch(l?.id?`${n}/${l.id}`:n,{method:l?.id?"PUT":"POST",headers:this.createHeaders("application/json"),body:JSON.stringify(m)}));if(u.ok)this.logger.info({...s,step:h,result:"success",http_status:u.status});else{if(this.shouldRetry(u.status,g,r)){await this.warnAndWait(s,h,r,u);continue}this.logger.error({...s,step:h,...r>1?{attempts:r}:{},http_status:u.status,http_status_text:u.statusText})}}catch(t){this.logger.error({...s,error:`${t}`})}return}}async defaultSignatures(t){try{const e=await this.requestLimit(()=>this.fetch(`${this.webadminUrl}/users/${t}/identities?default=true`,{method:"GET",headers:this.createHeaders()}));if(!e.ok)return{};const a=pickDefaultIdentity(await e.json()),i={};return a?.htmlSignature&&(i.htmlSignature=a.htmlSignature),a?.textSignature&&(i.textSignature=a.textSignature),i}catch(e){return this.logger.debug(`Could not read the identities of ${t}: ${e}`),{}}}ownForwards(t){return this.config.james_sync_forwards?this.getAliases(t):[]}async dropStaleForwards(t,e,a){let i;try{const s=await this.requestLimit(()=>this.fetch(`${this.webadminUrl}/address/forwards/${e}`,{method:"GET",headers:this.createHeaders()}));if(!s.ok)return void(404!==s.status&&this.logger.warn({plugin:this.name,event:"dropStaleForward",result:"error",dn:t,address:e,step:"list_forwards",http_status:s.status,http_status_text:s.statusText}));const r=a.map(t=>t.toLowerCase());i=(await s.json()).map(t=>t.mailAddress).filter(t=>t&&!r.includes(t.toLowerCase()))}catch(t){return void this.logger.debug(`Could not read the forwards of ${e}: ${t}`)}await Promise.all(i.map(a=>this.callWebAdminApi("dropStaleForward",`${this.webadminUrl}/address/forwards/${e}/targets/${a}`,"DELETE",t,null,{address:e,forward:a})))}async warnAndWait(t,e,a,i){this.logger.warn({...t,step:e,result:"retry",attempt:a,http_status:i.status,http_status_text:i.statusText}),await this.waitBeforeRetry(a)}async deleteUserData(t,e){const a={plugin:this.name,event:"deleteUserData",mail:t,fromStep:e};try{const i=new URL(`${this.webadminUrl}/users/${t}`);i.searchParams.set("action","deleteData"),e&&i.searchParams.set("fromStep",e);const s=await this.requestLimit(()=>this.fetch(i.toString(),{method:"POST",headers:this.createHeaders()}));if(!s.ok)return this.logger.error({...a,http_status:s.status,http_status_text:s.statusText}),null;const r=await s.json();return this.logger.info({...a,http_status:s.status,taskId:r.taskId}),r}catch(t){return this.logger.error({...a,error:`${t}`}),null}}getMailboxType(t){const e=t[this.mailboxTypeAttr];if(!e)return null;const a=Array.isArray(e)?String(e[0]):String(e),i=/^\s*cn\s*=\s*([^,]+)/i.exec(a);if(!i)return null;return{group:"group",mailinglist:"mailingList",teammailbox:"teamMailbox"}[i[1].trim().toLowerCase()]??null}async createTeamMailbox(t,e,a){const i=this.teamMailboxUrl(a);await this.callWebAdminApi("teamMailbox:create",i,"PUT",t,null,{teamMailbox:a}),await this.teamMailboxMembers(t,a,"PUT",members(e))}async getMemberEmails(t){const e=t.filter(t=>!o(t,this.config.group_dummy_user)).map(t=>this.server.ldap.queryLimit(async()=>{const e=await this.ldapGetAttributes(t,[this.mailAttr]);return e?this.attributeToString(e[this.mailAttr]):null}));return(await Promise.all(e)).filter(t=>null!==t)}async getGroupMail(t){const e=await this.ldapGetAttributes(t,["mail"]);return e?this.attributeToString(e.mail):null}async getGroupMailboxType(t){try{const e=await this.server.ldap.system.search({paged:!1,scope:"base",attributes:[this.mailboxTypeAttr]},t);if(e.searchEntries&&e.searchEntries.length>0)return this.getMailboxType(e.searchEntries[0])}catch(e){const a=e instanceof Error?e.message:String(e);this.logger.debug(`Could not get mailbox type for group ${t}: ${a}`)}return null}async _handleDelegationChange(t,e){const a=await this.ldapGetAttributes(t,[this.mailAttr]);if(!a)return void this.logger.warn({plugin:this.name,event:"onLdapChange",dn:t,message:"Could not find user entry to get mail attribute"});const i=a[this.mailAttr];if(!i||"string"!=typeof i)return void this.logger.warn({plugin:this.name,event:"onLdapChange",dn:t,message:"User has no mail attribute, cannot manage delegation"});const s=this.config.delegation_attribute;if(!s)return;const[r,n]=e[s]||[],o=this._normalizeToArray(r),l=this._normalizeToArray(n),d=l.filter(t=>!o.includes(t)),m=o.filter(t=>!l.includes(t)),h=d.map(t=>this.server.ldap.queryLimit(async()=>{const e=await this._getDelegateEmail(t);return{dn:t,email:e}})),u=m.map(t=>this.server.ldap.queryLimit(async()=>{const e=await this._getDelegateEmail(t);return{dn:t,email:e}})),[c,p]=await Promise.all([Promise.all(h),Promise.all(u)]),f=[];for(const{dn:e,email:a}of c)a&&f.push(this.callWebAdminApi("onLdapChange:addDelegation",`${this.webadminUrl}/users/${i}/authorizedUsers/${a}`,"PUT",t,null,{userMail:i,delegateEmail:a,delegateDN:e,action:"add"},g));for(const{dn:e,email:a}of p)a&&f.push(this.callWebAdminApi("onLdapChange:removeDelegation",`${this.webadminUrl}/users/${i}/authorizedUsers/${a}`,"DELETE",t,null,{userMail:i,delegateEmail:a,delegateDN:e,action:"remove"}));await Promise.all(f)}async _getDelegateEmail(t){const e=await this.ldapGetAttributes(t,[this.mailAttr]);return e?this.attributeToString(e[this.mailAttr]):(this.logger.warn({plugin:this.name,event:"getDelegateEmail",dn:t,message:"Could not resolve delegate DN to email"}),null)}_normalizeToArray(t){return t?Array.isArray(t)?t:[t]:[]}}export{James as default};
@@ -0,0 +1 @@
1
+ import{TwakePlugin as t}from"../../abstract/twakePlugin.js";import"node-fetch";import"../../lib/metrics.js";import{isDnInBranch as r,asyncHandler as e,normalizeDn as i}from"../../lib/utils.js";import{wantJson as s}from"../../lib/expressFormatedResponses.js";const a=[500,502,503,504];class MigrationProxy extends t{constructor(t){if(super(t,"migration_proxy_url","migration_proxy_token","migration_proxy_concurrency"),this.name="migrationProxy",this.roles=["consistency","api"],this.dependencies={onLdapChange:"core/ldap/onChange"},this.queues=new Map,this.hooks={onLdapEntryChange:(t,e,i)=>{if(this.branches.some(e=>r(t,e)))return this.inOrder(t,()=>this.follow(t,e,i))}},this.branches=this.config.migration_proxy_branch||[],!this.branches.length)throw new Error(`${this.name}: --migration-proxy-branch is required`);this.aliasAttr=this.config.alias_attribute||"mailAlternateAddress",this.retryAttempts="number"==typeof this.config.migration_proxy_retry_attempts?Math.max(1,this.config.migration_proxy_retry_attempts):5,this.retryDelay="number"==typeof this.config.migration_proxy_retry_delay?Math.max(0,this.config.migration_proxy_retry_delay):1e3}health(){return this.webadminHealth()}api(t){const r=this.config.api_prefix||"/api";t.get(`${r}/v1/users/:user/mail-migration`,e((t,r)=>this.getMigrationStatus(t,r)))}inOrder(t,r){let e;try{e=i(t)}catch{e=t.toLowerCase()}const s=(this.queues.get(e)??Promise.resolve()).then(r,r);return this.queues.set(e,s),s.finally(()=>{this.queues.get(e)===s&&this.queues.delete(e)})}addresses(t){if(!t)return{primary:null,all:[]};const r=this.getAliases(t[this.mailAttr]),e=new Set,i=[...r,...this.getAliases(t[this.aliasAttr])].filter(t=>{const r=t.toLowerCase();return!e.has(r)&&(e.add(r),!0)});return{primary:r[0]??null,all:i}}async follow(t,r,e){const i=this.addresses(r),s=this.addresses(e),lower=t=>new Set(t.map(t=>t.toLowerCase())),a=lower(i.all),o=lower(s.all),n=s.all.filter(t=>!a.has(t.toLowerCase())),l=i.all.filter(t=>!o.has(t.toLowerCase()));if(!n.length&&!l.length)return;let h;if(i.primary)if(s.primary){h="onLdapEntryChange:update";const r={plugin:this.name,event:h,dn:t,mail:i.primary,added:n,removed:l};let e;try{e=await this.isMigrated(i.primary,!0)}catch(t){return void this.logger.error({...r,result:"error",error:t instanceof Error?t.message:String(t)})}if(!e)return void this.logger.debug({...r,result:"ignored"})}else h="onLdapEntryChange:delete";else h="onLdapEntryChange:add";(await Promise.all(n.map(r=>this.call(h,t,"PUT",r)))).every(Boolean)&&await Promise.all(l.map(r=>this.call(h,t,"DELETE",r)))}userUrl(t){return`${this.webadminUrl}/migratedUsers/${encodeURIComponent(t)}`}call(t,r,e,i){return this.callWebAdminApi(t,this.userUrl(i),e,r,null,{mail:i},a)}async isMigrated(t,r=!1){for(let e=1;;e++){const i=await this.requestLimit(()=>this.fetch(this.userUrl(t),{method:"HEAD",headers:this.createHeaders(),signal:AbortSignal.timeout(1e4)}));if(404===i.status)return!1;if(i.ok)return!0;if(!r||!this.shouldRetry(i.status,a,e))throw new Error(`Migration proxy answered ${i.status} ${i.statusText}`);await this.waitBeforeRetry(e)}}async getMigrationStatus(t,r){if(!s(t,r))return;const e=t.params.user,i=await this.findReadableUser(e,t,[this.mailAttr]);if(!i)return void r.status(404).json({error:`User ${e} not found`});const a=this.attributeToString(i[this.mailAttr]);if(a)try{r.json({migrated:await this.isMigrated(a)})}catch(t){this.logger.error({plugin:this.name,event:"getMigrationStatus",username:e,error:t instanceof Error?t.message:String(t)}),r.status(502).json({error:"Failed to ask the migration proxy"})}else r.status(400).json({error:`User ${e} has no mail attribute`})}}export{MigrationProxy as default};
@@ -1 +1 @@
1
- import e from"../abstract/plugin.js";class WebLogs extends e{constructor(){super(...arguments),this.name="weblogs",this.roles=["logging"]}api(e){e.use((e,r,o)=>{let s=!0;this.logger.debug(`Incoming request: ${e.method} ${e.originalUrl}`);const t=Date.now();r.on("finish",()=>{const o={};e.proxyAuthUser?o.user=e.proxyAuthUser:e.user&&(o.user=e.user),s&&this.log(e,r,t,o),s=!1}),r.on("error",o=>{s&&this.log(e,r,t,{error:o.message}),s=!1}),r.on("close",()=>{r.writableEnded||(s&&this.log(e,r,t,{error:`Connection closed before response was sent for ${e.method} ${e.originalUrl}`}),s=!1)}),o()})}log(e,r,o,s){const t=Date.now()-o,n={...e.logDetails||{},method:e.method,url:e.originalUrl,status:r.statusCode,duration:t,ip:this.getClientIp(e),...s};this.logger.notice(n)}getClientIp(e){const r=e.headers["x-forwarded-for"];if(r){return(Array.isArray(r)?r[0]:r).split(",")[0].trim()}return e.socket.remoteAddress||"unknown"}}export{WebLogs as default};
1
+ import e from"../abstract/plugin.js";import{requestId as r}from"../lib/changeContext.js";import{runWithLogContext as o}from"../lib/requestContext.js";class WebLogs extends e{constructor(){super(...arguments),this.name="weblogs",this.roles=["logging"]}api(e){e.use((e,r,o)=>{let t=!0;this.logger.debug(`Incoming request: ${e.method} ${e.originalUrl}`);const s=Date.now();r.on("finish",()=>{const o={};e.proxyAuthUser?o.user=e.proxyAuthUser:e.user&&(o.user=e.user),t&&this.log(e,r,s,o),t=!1}),r.on("error",o=>{t&&this.log(e,r,s,{error:o.message}),t=!1}),r.on("close",()=>{r.writableEnded||(t&&this.log(e,r,s,{error:`Connection closed before response was sent for ${e.method} ${e.originalUrl}`}),t=!1)}),o()})}log(e,t,s,n){const i=Date.now()-s,l={...e.logDetails||{},requestId:r(e),method:e.method,url:e.originalUrl,status:t.statusCode,duration:i,ip:this.getClientIp(e),...n};o({requestId:l.requestId},()=>this.logger.notice(l))}getClientIp(e){const r=e.headers["x-forwarded-for"];if(r){return(Array.isArray(r)?r[0]:r).split(",")[0].trim()}return e.socket.remoteAddress||"unknown"}}export{WebLogs as default};
@@ -12,6 +12,7 @@ import type { DM } from '../bin';
12
12
  import type ldapActions from '../lib/ldapActions';
13
13
  import type { AttributesList, AttributeValue, LdapList, ModifyRequest } from '../lib/ldapActions';
14
14
  import type { Schema } from '../config/schema';
15
+ import { EntityValidator } from '../lib/entitySchema/validator';
15
16
  import DmPlugin from './plugin';
16
17
  /** What {@link LdapFlat.listEntries} is asked for. */
17
18
  export interface ListEntriesOptions {
@@ -220,17 +221,6 @@ export default abstract class LdapFlat extends DmPlugin {
220
221
  pluralName: string;
221
222
  hookPrefix: string;
222
223
  constructor(server: DM, config: LdapFlatConfig);
223
- /**
224
- * Attributes a client is not allowed to supply: those the server computes
225
- * (`generated`) and those it only ever derives from elsewhere (`readOnly`,
226
- * such as `memberOf`, which is driven from the group side).
227
- *
228
- * The core and its plugins still write them — the restriction is on the
229
- * request body, not on the entry.
230
- *
231
- * @returns lowercased attribute names
232
- */
233
- protected clientForbiddenAttributes(): Set<string>;
234
224
  /**
235
225
  * Attributes never sent back over the API. A manager may reset a password
236
226
  * without being able to read it back: the asymmetry is a projection, not an
@@ -324,7 +314,8 @@ export default abstract class LdapFlat extends DmPlugin {
324
314
  * @returns true when the DN is taken
325
315
  */
326
316
  protected rdnExists(value: string): Promise<boolean>;
327
- protected invalidValueMessage(field: string): string;
317
+ /** The schema's rules; the pointer lookups run as the server */
318
+ protected validator(): EntityValidator;
328
319
  /**
329
320
  * API routes
330
321
  */
@@ -432,5 +423,4 @@ export default abstract class LdapFlat extends DmPlugin {
432
423
  searchEntriesByName(name: string, partial?: boolean, attrs?: string[]): Promise<LdapList>;
433
424
  validateNewEntry(dn: string, entry: AttributesList): Promise<boolean>;
434
425
  validateChanges(dn: string, changes: ModifyRequest): Promise<boolean>;
435
- _validateOneChange(field: string, value: AttributeValue | null): Promise<boolean>;
436
426
  }
@@ -3,11 +3,14 @@
3
3
  * @author Xavier Guimard <xguimard@linagora.com>
4
4
  */
5
5
  import type { Express } from 'express';
6
+ import fetch, { type RequestInit, type Response } from 'node-fetch';
6
7
  import type winston from 'winston';
7
8
  import type { Config, DM } from '../bin';
8
9
  import type { Hooks, MaybePromise } from '../hooks';
10
+ import type { HealthStatus } from '../lib/health';
9
11
  export { asyncHandler, escapeDnValue, unescapeDnValue, escapeLdapFilter, validateDnValue, } from '../lib/utils';
10
12
  export { HttpError, BadRequestError, UnauthorizedError, ForbiddenError, NotFoundError, ConflictError, UriTooLongError, TooManyRequestsError, BadGatewayError, ServiceUnavailableError, GatewayTimeoutError, } from '../lib/errors';
13
+ export type { HealthStatus };
11
14
  export type Role = 'auth' | 'authz' | 'protect' | 'api' | 'logging' | 'demo' | 'consistency' | 'configurable';
12
15
  export default abstract class DmPlugin {
13
16
  /**
@@ -56,7 +59,24 @@ export default abstract class DmPlugin {
56
59
  * there, or a service it cannot work without.
57
60
  */
58
61
  assertComposition?(): MaybePromise<void>;
62
+ /**
63
+ * Asked by `GET /readyz`: `degraded` when a resource this plugin depends
64
+ * on does not answer while the server still serves. Only the directory
65
+ * makes the server not ready, see `docs/usage/operations.md`.
66
+ */
67
+ health?(): MaybePromise<HealthStatus>;
68
+ /**
69
+ * Called once by the shutdown, after the server stopped serving: let go
70
+ * of what this plugin holds, a connection, a timer, a queue. See
71
+ * `docs/usage/operations.md#shutdown`.
72
+ */
73
+ close?(): MaybePromise<void>;
59
74
  abstract name: string;
75
+ /**
76
+ * Call another service: `fetch`, timed into the outbound metrics under
77
+ * this plugin's name, see `docs/usage/operations.md#metrics`.
78
+ */
79
+ protected fetch(url: Parameters<typeof fetch>[0], init?: RequestInit): Promise<Response>;
60
80
  /**
61
81
  * Constructor
62
82
  * @param server DM object
@@ -1,5 +1,12 @@
1
+ /**
2
+ * Abstract base class for Twake integration plugins
3
+ * Provides common HTTP request handling, concurrency control, and LDAP utilities
4
+ * @author Xavier Guimard <xguimard@linagora.com>
5
+ */
6
+ import type { Request } from 'express';
1
7
  import pLimit from 'p-limit';
2
8
  import type { DM } from '../bin';
9
+ import { type HealthStatus } from '../lib/health';
3
10
  import type { AttributesList } from '../lib/ldapActions';
4
11
  import DmPlugin, { type Role } from './plugin';
5
12
  /**
@@ -43,8 +50,23 @@ export declare abstract class TwakePlugin extends DmPlugin {
43
50
  * @param fields Additional fields for logging
44
51
  * @param retryOn HTTP statuses worth another attempt, up to
45
52
  * `retryAttempts` in all; none by default
53
+ * @returns whether the call succeeded, an ignored error counting as a
54
+ * success; a failure is logged here, the caller decides what follows
55
+ */
56
+ protected callWebAdminApi(hookname: string, url: string, method: string, dn: string, body: string | null, fields: object, retryOn?: readonly number[]): Promise<boolean>;
57
+ /**
58
+ * The service's own health check, for the readiness probe: `degraded`
59
+ * when it answers an error, or not under the probe's budget, so that a
60
+ * mute service reads as degraded rather than leaving the probe to time out
61
+ */
62
+ protected webadminHealth(): Promise<HealthStatus>;
63
+ /**
64
+ * The user a route is asked about, by `uid` across the directory, with
65
+ * `attributes`; null when there is none the caller may read, so that a
66
+ * user the caller cannot read gets the answer a user that is not there
67
+ * gets
46
68
  */
47
- protected callWebAdminApi(hookname: string, url: string, method: string, dn: string, body: string | null, fields: object, retryOn?: readonly number[]): Promise<void>;
69
+ protected findReadableUser(username: string, req: Request, attributes: string[]): Promise<AttributesList | null>;
48
70
  /**
49
71
  * Whether a failed call is worth another attempt
50
72
  * @param statusCode HTTP status code of the failure
@@ -30,7 +30,9 @@ export declare class DM {
30
30
  app: express.Express;
31
31
  config: Config;
32
32
  ready: Promise<void>;
33
- server?: import('http').Server;
33
+ /** The listener, once `run()` opened it; shared by the `withConfig` views */
34
+ get server(): import('http').Server | undefined;
35
+ set server(server: import('http').Server | undefined);
34
36
  hooks: {
35
37
  [K in keyof Hooks]?: Function[];
36
38
  };
@@ -60,6 +62,21 @@ export declare class DM {
60
62
  * assembled by hand — is checked on its own as it arrives.
61
63
  */
62
64
  private _composed;
65
+ /** Every plugin loaded and the composition checked */
66
+ get composed(): boolean;
67
+ /**
68
+ * The listener, the shutdown once started, and the rejections nobody
69
+ * handled since the start. In an object, shared by the `withConfig` views
70
+ * as `_authState` is: a view with a flag of its own would stop on its own,
71
+ * and one without the listener would not close it.
72
+ */
73
+ private readonly life;
74
+ /** The responses not sent yet, told to close their connection on a stop */
75
+ private readonly inFlight;
76
+ /** Told to stop: the readiness says so, what is in flight finishes */
77
+ get stopping(): boolean;
78
+ /** Rejections nobody handled since the start, logged and counted */
79
+ get unhandledRejections(): number;
63
80
  private _errorMiddleware?;
64
81
  /**
65
82
  * The loads of `--plugin` entries without overrides, keyed by resolved
@@ -195,8 +212,36 @@ export declare class DM {
195
212
  */
196
213
  warnUnauthenticatedRoutes(): string[];
197
214
  setupErrorMiddleware(): void;
215
+ /**
216
+ * Exit on a failure nothing caught: an exception leaves the process in a
217
+ * state nobody knows, so it shuts down as cleanly as it can, within the
218
+ * shutdown budget, and exits with 1 for its supervisor to start it again.
219
+ * A rejection nothing handled is logged and counted; the request that
220
+ * owned it has been answered or will time out.
221
+ *
222
+ * For the binary; a host assembling the server decides for itself, and
223
+ * without it Node's own action applies.
224
+ */
225
+ exitOnCrash(): void;
198
226
  run(): Promise<void>;
199
227
  stop(): void;
228
+ /**
229
+ * Stop serving: the readiness answers 503, the listening socket closes,
230
+ * what is in flight finishes, then every plugin's `close()` runs and the
231
+ * directory connections are closed, the whole within `--shutdown-timeout`
232
+ * whatever hangs. Called once; a second call waits for the first.
233
+ *
234
+ * @returns when everything let go, resolved whatever failed on the way
235
+ */
236
+ shutdown(): Promise<void>;
237
+ private letGo;
238
+ /** @returns whether `done` settled before `ms` passed */
239
+ private static within;
240
+ /**
241
+ * Shut down on SIGTERM and SIGINT, then let the signal end the process.
242
+ * For the binary; a host assembling the server decides for itself.
243
+ */
244
+ shutdownOnSignals(): void;
200
245
  /**
201
246
  * The module part of a `--plugin` entry.
202
247
  *
@@ -32,7 +32,10 @@ export interface Config {
32
32
  plugin?: string[];
33
33
  schemas_path: string;
34
34
  log_level: 'error' | 'warn' | 'notice' | 'info' | 'debug';
35
- logger: 'console';
35
+ shutdown_timeout?: number;
36
+ debug: boolean;
37
+ logger: 'console' | 'file';
38
+ log_file?: string;
36
39
  api_prefix: string;
37
40
  mail_domain?: string[];
38
41
  ldap_base?: string;
@@ -53,6 +56,8 @@ export interface Config {
53
56
  groups_allow_unexistent_members?: boolean;
54
57
  group_dummy_user?: string;
55
58
  group_schema?: string;
59
+ /** Where core/metrics serves `GET /metrics` */
60
+ metrics_port?: number;
56
61
  /**
57
62
  * Keyed storage, shared by whoever needs to keep something with a
58
63
  * deadline. `storage_backend` names the sub-plugin that keeps it; the rest
@@ -197,6 +202,12 @@ export interface Config {
197
202
  james_mailbox_type_attribute?: string;
198
203
  james_sync_forwards?: boolean;
199
204
  james_team_mailbox_any_domain?: boolean;
205
+ migration_proxy_url?: string;
206
+ migration_proxy_token?: string;
207
+ migration_proxy_branch?: string[];
208
+ migration_proxy_concurrency?: number;
209
+ migration_proxy_retry_attempts?: number;
210
+ migration_proxy_retry_delay?: number;
200
211
  twake_drive_webadmin_url?: string;
201
212
  twake_drive_webadmin_token?: string;
202
213
  twake_drive_concurrency?: number;
@@ -14,6 +14,17 @@ export interface ChangeContext {
14
14
  * @param source its name, e.g. `scim`
15
15
  */
16
16
  export declare function setChangeSource(req: Request, source: string): void;
17
+ /**
18
+ * The identifier of a request, shared by its writes, its log lines and the
19
+ * `X-Request-Id` of its answer.
20
+ *
21
+ * Generated rather than read from an `X-Request-Id` header: a client could
22
+ * otherwise make the writes of two requests look like one.
23
+ *
24
+ * @param req the request
25
+ * @returns its identifier, the same on every call
26
+ */
27
+ export declare function requestId(req: Request): string;
17
28
  /**
18
29
  * The context of a write, handed to the "done" hooks.
19
30
  *