ldap-rest 0.9.0 → 0.10.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 (40) hide show
  1. package/bin/index.mjs +1 -1
  2. package/dist/abstract/ldapFlat.js +1 -1
  3. package/dist/lib/authz/base.js +1 -1
  4. package/dist/lib/changeContext.js +1 -0
  5. package/dist/lib/errors.js +1 -1
  6. package/dist/lib/ldapActions.js +1 -1
  7. package/dist/plugins/auth/authzLinid1.js +1 -1
  8. package/dist/plugins/auth/authzPerBranch.js +1 -1
  9. package/dist/plugins/ldap/departmentSync.js +1 -1
  10. package/dist/plugins/ldap/groups.js +1 -1
  11. package/dist/plugins/ldap/onChange.js +1 -1
  12. package/dist/plugins/ldap/organizations.js +1 -1
  13. package/dist/plugins/ldap/trash.js +1 -1
  14. package/dist/plugins/scim/scim.js +1 -1
  15. package/dist/plugins/twake/appAccountsApi.js +1 -1
  16. package/dist/plugins/twake/appAccountsConsistency.js +1 -1
  17. package/dist/plugins/twake/clouderyProvision.js +1 -1
  18. package/dist/plugins/twake/cozyProvision.js +1 -1
  19. package/dist/src/abstract/plugin.d.ts +6 -0
  20. package/dist/src/hooks.d.ts +15 -6
  21. package/dist/src/lib/authz/base.d.ts +26 -0
  22. package/dist/src/lib/changeContext.d.ts +24 -0
  23. package/dist/src/lib/errors.d.ts +15 -0
  24. package/dist/src/lib/ldapActions.d.ts +2 -1
  25. package/dist/src/plugins/auth/authzLinid1.d.ts +6 -0
  26. package/dist/src/plugins/auth/authzPerBranch.d.ts +35 -0
  27. package/dist/src/plugins/ldap/groups.d.ts +7 -5
  28. package/dist/src/plugins/ldap/onChange.d.ts +17 -8
  29. package/dist/src/plugins/ldap/organizations.d.ts +2 -2
  30. package/docs/plugin-development/README.md +1 -18
  31. package/docs/plugin-development/hooks.md +6 -0
  32. package/docs/plugin-development/testing.md +1 -17
  33. package/docs/usage/configuration.md +1 -1
  34. package/docs/usage/plugins/auth/authz-linid1.md +8 -7
  35. package/docs/usage/plugins/auth/authz-per-branch.md +33 -11
  36. package/docs/usage/plugins/auth/authz-scope.md +5 -0
  37. package/docs/usage/plugins/ldap/on-change.md +51 -284
  38. package/docs/usage/troubleshooting.md +1 -9
  39. package/docs/usage/upgrading.md +52 -0
  40. package/package.json +1 -1
@@ -1 +1 @@
1
- import t from"../../abstract/plugin.js";import{isDnInBranch as e,escapeDnValue as i}from"../../lib/utils.js";function isNoSuchObject(t){if("object"!=typeof t||null===t)return!1;const{code:e,name:i,message:a}=t;return 32===e||"NoSuchObjectError"===i||"string"==typeof a&&a.includes("Code: 0x20")}class AppAccountsConsistency extends t{constructor(t){super(t),this.name="appAccountsConsistency",this.roles=["consistency"],this.dependencies={onLdapChange:"core/ldap/onChange"},this.hooks={onLdapMailChange:async(t,e,i)=>{if(this.isInApplicativeBranch(t))this.logger.debug(`${this.name}: Ignoring mail change event originating in the applicative branch`);else try{const a=null!=e?Array.isArray(e)?String(e[0]):String(e):null,c=null!=i?Array.isArray(i)?String(i[0]):String(i):null;if(!a&&c)return void await this.createApplicativeAccount(t,c);if(a&&!c)return void await this.deleteApplicativeAccount(a);if(a&&c&&a!==c)return void await this.updateApplicativeAccount(t,a,c)}catch(e){this.logger.error(`${this.name}: Failed to handle mail change for ${t}:`,e)}}},this.mailAttr=this.config.mail_attribute||"mail",this.applicativeAccountBase=this.config.applicative_account_base,this.operationalAttributes=this.config.ldap_operational_attribute||[];const e=this.config.applicative_account_attribute||[],i=["objectClass",this.mailAttr],a=i.filter(t=>!e.includes(t));if(e.length>0&&a.length>0&&this.logger.warn(`${this.name}: applicative_account_attribute omits ${a.join(", ")} — copied anyway, an applicative entry cannot be created without them`),this.copiedAttributes=new Set([...e,...i]),!this.applicativeAccountBase)throw new Error(`${this.name}: applicative_account_base configuration is required`);this.logger.info(`${this.name}: initialized with applicative_account_base=${this.applicativeAccountBase}`)}shouldExcludeAttribute(t){return"dn"===t||this.operationalAttributes.includes(t)}async deleteApplicativeEntry(t){try{await this.server.ldap.delete(t),this.logger.info(`${this.name}: Deleted applicative account ${t}`)}catch(e){if(isNoSuchObject(e))return void this.logger.debug(`${this.name}: Applicative account ${t} already deleted`);throw e}}pickCopiedAttributes(t){const e={};for(const[i,a]of Object.entries(t))this.copiedAttributes.has(i)&&null!=a&&(Array.isArray(a)&&0===a.length||(e[i]=a));return e}isInApplicativeBranch(t){return e(t,this.applicativeAccountBase)}async createApplicativeAccount(t,e){const a=`uid=${i(e)},${this.applicativeAccountBase}`;try{const i=await this.server.ldap.search({scope:"base",paged:!1},t),c=i.searchEntries?.[0];if(!c)return void this.logger.warn(`${this.name}: Could not find user ${t} to create applicative account`);const s=this.pickCopiedAttributes(c);s.uid=e,await this.server.ldap.add(a,s),this.logger.info(`${this.name}: Created applicative account ${a} for user ${t}`)}catch(e){if(68===e.code||e.message?.includes("AlreadyExists"))return void this.logger.debug(`${this.name}: Applicative account ${a} already exists`);this.logger.error(`${this.name}: Failed to create applicative account for ${t}:`,e)}}async deleteApplicativeAccount(t){try{const e=`(${this.mailAttr}=${t})`,i=(await this.server.ldap.search({filter:e,scope:"sub",paged:!1},this.applicativeAccountBase)).searchEntries||[];if(0===i.length)return void this.logger.debug(`${this.name}: No applicative accounts found for mail ${t}`);for(const t of i){const e=t.dn;try{await this.deleteApplicativeEntry(e)}catch(t){this.logger.error(`${this.name}: Failed to delete applicative account ${e}:`,t)}}}catch(e){if(isNoSuchObject(e))return void this.logger.debug(`${this.name}: Applicative account base does not exist or no accounts found for mail ${t}`);this.logger.error(`${this.name}: Failed to delete applicative account for mail ${t}:`,e)}}async updateApplicativeAccount(t,e,a){try{const c=`(${this.mailAttr}=${e})`,s=(await this.server.ldap.search({filter:c,scope:"sub",paged:!1},this.applicativeAccountBase)).searchEntries||[];if(0===s.length)return void this.logger.debug(`${this.name}: No applicative account found for old mail ${e}`);for(const c of s){const s=c.dn,r=Array.isArray(c.uid)?String(c.uid[0]):String(c.uid);if(!(r===e)){await this.deleteApplicativeEntry(s);continue}const n=`uid=${i(a)},${this.applicativeAccountBase}`;try{const i={};for(const[t,e]of Object.entries(c))this.shouldExcludeAttribute(t)||null!=e&&(Array.isArray(e)&&0===e.length||(i[t]=e));await this.deleteApplicativeEntry(s);const r={...i},o=await this.server.ldap.search({scope:"base",paged:!1},t),l=o.searchEntries?.[0];if(!l)return void this.logger.warn(`${this.name}: Could not find user ${t} to update applicative account`);Object.assign(r,this.pickCopiedAttributes(l)),r.uid=a,await this.server.ldap.add(n,r),this.logger.info(`${this.name}: Created new applicative account ${n} (mail changed from ${e} to ${a})`)}catch(t){if(68===t.code||t.message?.includes("AlreadyExists"))return void this.logger.debug(`${this.name}: Applicative account ${n} already exists after mail change`);this.logger.error(`${this.name}: Failed to update applicative account for mail change:`,t)}}}catch(e){this.logger.error(`${this.name}: Failed to update applicative account for ${t}:`,e)}}}export{AppAccountsConsistency as default};
1
+ import t from"../../abstract/plugin.js";import{isDnInBranch as e,escapeDnValue as i,escapeLdapFilter as a}from"../../lib/utils.js";function isNoSuchObject(t){if("object"!=typeof t||null===t)return!1;const{code:e,name:i,message:a}=t;return 32===e||"NoSuchObjectError"===i||"string"==typeof a&&a.includes("Code: 0x20")}class AppAccountsConsistency extends t{constructor(t){super(t),this.name="appAccountsConsistency",this.roles=["consistency"],this.dependencies={onLdapChange:"core/ldap/onChange"},this.hooks={onLdapMailChange:async(t,e,i)=>{if(this.isInApplicativeBranch(t))this.logger.debug(`${this.name}: Ignoring mail change event originating in the applicative branch`);else try{const a=null!=e?Array.isArray(e)?String(e[0]):String(e):null,c=null!=i?Array.isArray(i)?String(i[0]):String(i):null;if(!a&&c)return void await this.createApplicativeAccount(t,c);if(a&&!c)return void await this.deleteApplicativeAccount(a);if(a&&c&&a!==c)return void await this.updateApplicativeAccount(t,a,c)}catch(e){this.logger.error(`${this.name}: Failed to handle mail change for ${t}:`,e)}}},this.mailAttr=this.config.mail_attribute||"mail",this.applicativeAccountBase=this.config.applicative_account_base,this.operationalAttributes=this.config.ldap_operational_attribute||[];const e=this.config.applicative_account_attribute||[],i=["objectClass",this.mailAttr],a=i.filter(t=>!e.includes(t));if(e.length>0&&a.length>0&&this.logger.warn(`${this.name}: applicative_account_attribute omits ${a.join(", ")} — copied anyway, an applicative entry cannot be created without them`),this.copiedAttributes=new Set([...e,...i]),!this.applicativeAccountBase)throw new Error(`${this.name}: applicative_account_base configuration is required`);this.logger.info(`${this.name}: initialized with applicative_account_base=${this.applicativeAccountBase}`)}shouldExcludeAttribute(t){return"dn"===t||this.operationalAttributes.includes(t)}async deleteApplicativeEntry(t){try{await this.server.ldap.delete(t),this.logger.info(`${this.name}: Deleted applicative account ${t}`)}catch(e){if(isNoSuchObject(e))return void this.logger.debug(`${this.name}: Applicative account ${t} already deleted`);throw e}}pickCopiedAttributes(t){const e={};for(const[i,a]of Object.entries(t))this.copiedAttributes.has(i)&&null!=a&&(Array.isArray(a)&&0===a.length||(e[i]=a));return e}isInApplicativeBranch(t){return e(t,this.applicativeAccountBase)}async createApplicativeAccount(t,e){const a=`uid=${i(e)},${this.applicativeAccountBase}`;try{const i=await this.server.ldap.search({scope:"base",paged:!1},t),c=i.searchEntries?.[0];if(!c)return void this.logger.warn(`${this.name}: Could not find user ${t} to create applicative account`);const s=this.pickCopiedAttributes(c);s.uid=e,await this.server.ldap.add(a,s),this.logger.info(`${this.name}: Created applicative account ${a} for user ${t}`)}catch(e){if(68===e.code||e.message?.includes("AlreadyExists"))return void this.logger.debug(`${this.name}: Applicative account ${a} already exists`);this.logger.error(`${this.name}: Failed to create applicative account for ${t}:`,e)}}async deleteApplicativeAccount(t){try{const e=`(${this.mailAttr}=${a(t)})`,i=(await this.server.ldap.search({filter:e,scope:"sub",paged:!1},this.applicativeAccountBase)).searchEntries||[];if(0===i.length)return void this.logger.debug(`${this.name}: No applicative accounts found for mail ${t}`);for(const t of i){const e=t.dn;try{await this.deleteApplicativeEntry(e)}catch(t){this.logger.error(`${this.name}: Failed to delete applicative account ${e}:`,t)}}}catch(e){if(isNoSuchObject(e))return void this.logger.debug(`${this.name}: Applicative account base does not exist or no accounts found for mail ${t}`);this.logger.error(`${this.name}: Failed to delete applicative account for mail ${t}:`,e)}}async updateApplicativeAccount(t,e,c){try{const s=`(${this.mailAttr}=${a(e)})`,r=(await this.server.ldap.search({filter:s,scope:"sub",paged:!1},this.applicativeAccountBase)).searchEntries||[];if(0===r.length)return void this.logger.debug(`${this.name}: No applicative account found for old mail ${e}`);for(const a of r){const s=a.dn,r=Array.isArray(a.uid)?String(a.uid[0]):String(a.uid);if(!(r===e)){await this.deleteApplicativeEntry(s);continue}const n=`uid=${i(c)},${this.applicativeAccountBase}`;try{const i={};for(const[t,e]of Object.entries(a))this.shouldExcludeAttribute(t)||null!=e&&(Array.isArray(e)&&0===e.length||(i[t]=e));await this.deleteApplicativeEntry(s);const r={...i},o=await this.server.ldap.search({scope:"base",paged:!1},t),l=o.searchEntries?.[0];if(!l)return void this.logger.warn(`${this.name}: Could not find user ${t} to update applicative account`);Object.assign(r,this.pickCopiedAttributes(l)),r.uid=c,await this.server.ldap.add(n,r),this.logger.info(`${this.name}: Created new applicative account ${n} (mail changed from ${e} to ${c})`)}catch(t){if(68===t.code||t.message?.includes("AlreadyExists"))return void this.logger.debug(`${this.name}: Applicative account ${n} already exists after mail change`);this.logger.error(`${this.name}: Failed to update applicative account for mail change:`,t)}}}catch(e){this.logger.error(`${this.name}: Failed to update applicative account for ${t}:`,e)}}}export{AppAccountsConsistency 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{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 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();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 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 s=r.trim().toLowerCase(),n=this.createStash.get(s);if(!n)return void this.logger.info({plugin:this.name,event:"provision",userName:t,email:r.trim(),result:"skipped",reason:"no_stashed_context"});this.createStash.delete(s),this.logger.info({plugin:this.name,event:"provision",userName:t,email:n.email,orgId:n.orgId,base:n.base,role:n.role,result:"context_loaded"});const a=n.email,o=emailDomain(a),l=t.replace(/\./g,"")+n.orgId;const u=this.extractPrimaryPhone(e),h=this.extractPhones(e),g=this.extractPublicName(e)||t,d={plugin:this.name,event:"provision",userName:t,orgId:n.orgId,slug:l},c=`${l}.${this.clouderyDomain}`.toLowerCase();let m,p=null;try{p=await this.findInstanceUuidByFqdn(c)}catch(e){this.logger.warn({...d,result:"existence_lookup_failed",fqdn:c,error:`${e}`})}if(p)this.logger.info({...d,result:"instance_exists",fqdn:c,uuid:p}),m=c;else{let t;try{this.logger.info({...d,result:"creating_instance",orgDomain:o,email:a,mobile:u||void 0}),t=await this.createInstance({email:a,publicName:g,locale:this.extractLocale(e),oidc:l,phone:u||"",slug:l,public_name:g,offer:this.offer,domain:this.clouderyDomain,skip_email_validation:!0,internal_email:a,org_domain:o,org_id:n.orgId})}catch(e){return void this.logger.error({...d,result:"error",error:`${e}`})}if(!(!t.workflow||await this.waitForWorkflow(t.workflow)))return void this.logger.error({...d,result:"workflow_failed",workflow:t.workflow});m=t.fqdn}const f=`${this.rdnAttribute}=${i(t)},${n.base}`,w={[this.fqdnAttribute]:m,[this.orgIdAttribute]:n.orgId,[this.orgRoleAttribute]:n.role,[this.invitedAttribute]:"TRUE",cn:t};h.length>0&&(w[this.phonesAttribute]=JSON.stringify(h));try{this.logger.info({...d,result:"writing_fqdn",dn:f,hasPhones:h.length>0}),await this.server.ldap.modify(f,{replace:w}),this.logger.info({...d,result:"written_fqdn",dn:f})}catch(e){this.logger.error({...d,event:"writeFqdn",dn:f,error:`${e}`})}await this.publishUserCreated({twakeId:t,orgDomain:o,orgId:n.orgId,fqdn:m,email:a,mobile:u}),this.logger.info({...d,result:"success",fqdn:m})}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.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.findInstanceUuidByFqdn(t.fqdn);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,o);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,o);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 findInstanceUuidByFqdn(e){this.logger.info({plugin:this.name,event:"findInstanceUuidByFqdn",fqdn:e});const r=new URL(`${this.managerUrl}/api/v2/instances`);r.searchParams.set("fqdn",e),r.searchParams.append("only","_id"),r.searchParams.set("limit","1");const i=await t(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:"findInstanceUuidByFqdn",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&&s.items.length>0?s.items[0]._id:null;return this.logger.info({plugin:this.name,event:"findInstanceUuidByFqdn",fqdn:e,result:n?"found":"not_found",uuid:n||void 0}),n}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,o);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){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
+ import{setTimeout as e}from"node:timers/promises";import t from"node-fetch";import r from"../../abstract/plugin.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 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();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 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 s=r.trim().toLowerCase(),n=this.createStash.get(s);if(!n)return void this.logger.info({plugin:this.name,event:"provision",userName:t,email:r.trim(),result:"skipped",reason:"no_stashed_context"});this.createStash.delete(s),this.logger.info({plugin:this.name,event:"provision",userName:t,email:n.email,orgId:n.orgId,base:n.base,role:n.role,result:"context_loaded"});const a=n.email,o=emailDomain(a),l=t.replace(/\./g,"")+n.orgId;const u=this.extractPrimaryPhone(e),h=this.extractPhones(e),g=this.extractPublicName(e)||t,d={plugin:this.name,event:"provision",userName:t,orgId:n.orgId,slug:l},c=`${l}.${this.clouderyDomain}`.toLowerCase();let m,p=null;try{p=await this.findInstanceUuidByFqdn(c)}catch(e){this.logger.warn({...d,result:"existence_lookup_failed",fqdn:c,error:`${e}`})}if(p)this.logger.info({...d,result:"instance_exists",fqdn:c,uuid:p}),m=c;else{let t;try{this.logger.info({...d,result:"creating_instance",orgDomain:o,email:a,mobile:u||void 0}),t=await this.createInstance({email:a,publicName:g,locale:this.extractLocale(e),oidc:l,phone:u||"",slug:l,public_name:g,offer:this.offer,domain:this.clouderyDomain,skip_email_validation:!0,internal_email:a,org_domain:o,org_id:n.orgId})}catch(e){return void this.logger.error({...d,result:"error",error:`${e}`})}if(!(!t.workflow||await this.waitForWorkflow(t.workflow)))return void this.logger.error({...d,result:"workflow_failed",workflow:t.workflow});m=t.fqdn}const f=`${this.rdnAttribute}=${i(t)},${n.base}`,w={[this.fqdnAttribute]:m,[this.orgIdAttribute]:n.orgId,[this.orgRoleAttribute]:n.role,[this.invitedAttribute]:"TRUE",cn:t};h.length>0&&(w[this.phonesAttribute]=JSON.stringify(h));try{this.logger.info({...d,result:"writing_fqdn",dn:f,hasPhones:h.length>0}),await this.server.ldap.modify(f,{replace:w}),this.logger.info({...d,result:"written_fqdn",dn:f})}catch(e){this.logger.error({...d,event:"writeFqdn",dn:f,error:`${e}`})}await this.publishUserCreated({twakeId:t,orgDomain:o,orgId:n.orgId,fqdn:m,email:a,mobile:u}),this.logger.info({...d,result:"success",fqdn:m})}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.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.findInstanceUuidByFqdn(t.fqdn);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,o);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,o);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 findInstanceUuidByFqdn(e){this.logger.info({plugin:this.name,event:"findInstanceUuidByFqdn",fqdn:e});const r=new URL(`${this.managerUrl}/api/v2/instances`);r.searchParams.set("fqdn",e),r.searchParams.append("only","_id"),r.searchParams.set("limit","1");const i=await t(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:"findInstanceUuidByFqdn",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&&s.items.length>0?s.items[0]._id:null;return this.logger.info({plugin:this.name,event:"findInstanceUuidByFqdn",fqdn:e,result:n?"found":"not_found",uuid:n||void 0}),n}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,o);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?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){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"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};
@@ -25,6 +25,12 @@ export default abstract class DmPlugin {
25
25
  hooks?: Hooks;
26
26
  dependencies?: Record<string, string>;
27
27
  roles?: Role[] | undefined;
28
+ /**
29
+ * Operational attributes this plugin follows through `onLdapEntryChange`,
30
+ * e.g. `pwdAccountLockedTime`. A search returns none unless asked by name,
31
+ * so `core/ldap/onChange` requests these beside `*`.
32
+ */
33
+ followedOperationalAttributes?: string[];
28
34
  api?(app: Express): MaybePromise<void>;
29
35
  getConfigApiData?(): Record<string, unknown> | undefined;
30
36
  /**
@@ -2,10 +2,11 @@
2
2
  * Types for hooks
3
3
  * @author Xavier Guimard <xguimard@linagora.com>
4
4
  */
5
- import type { SearchOptions, SearchResult } from 'ldapts';
5
+ import type { Entry, SearchOptions, SearchResult } from 'ldapts';
6
6
  import type { Request, Response } from 'express';
7
7
  import type { ModifyRequest, AttributesList, AttributeValue } from './lib/ldapActions';
8
8
  import type { ChangesToNotify } from './plugins/ldap/onChange';
9
+ import type { ChangeContext } from './lib/changeContext';
9
10
  import * as utils from './lib/utils';
10
11
  export type MaybePromise<T> = Promise<T> | T;
11
12
  export type ChainedHook<T> = (arg: T) => MaybePromise<T>;
@@ -49,13 +50,13 @@ export interface Hooks {
49
50
  */
50
51
  ldapsearchfilter?: ChainedHook<[SearchResult, Request?, SearchOptions?]>;
51
52
  ldapaddrequest?: ChainedHook<[string, AttributesList, Request?]>;
52
- ldapadddone?: (args: [string, AttributesList]) => MaybePromise<void>;
53
+ ldapadddone?: (args: [string, AttributesList], context?: ChangeContext) => MaybePromise<void>;
53
54
  ldapmodifyrequest?: ChainedHook<[string, ModifyRequest, number, Request?]>;
54
- ldapmodifydone?: (args: [string, ModifyRequest, number]) => MaybePromise<void>;
55
+ ldapmodifydone?: (args: [string, ModifyRequest, number], context?: ChangeContext) => MaybePromise<void>;
55
56
  ldapdeleterequest?: ChainedHook<[string | string[], Request?]>;
56
- ldapdeletedone?: (dn: string | string[]) => MaybePromise<void>;
57
+ ldapdeletedone?: (dn: string | string[], context?: ChangeContext) => MaybePromise<void>;
57
58
  ldaprenamerequest?: ChainedHook<[string, string, Request?]>;
58
- ldaprenamedone?: (args: [string, string]) => MaybePromise<void>;
59
+ ldaprenamedone?: (args: [string, string], context?: ChangeContext) => MaybePromise<void>;
59
60
  /**
60
61
  * Plugins
61
62
  */
@@ -101,7 +102,15 @@ export interface Hooks {
101
102
  ldapgroupaddmember?: ChainedHook<[string, string[]]>;
102
103
  ldapgroupdeletemember?: ChainedHook<[string, string[]]>;
103
104
  _ldapgrouplist?: ChainedHook<AsyncGenerator<SearchResult>>;
104
- /** "onLdapChange" */
105
+ /**
106
+ * "onLdapChange"
107
+ *
108
+ * `onLdapEntryChange` gives the entry as the directory held it before and
109
+ * after an add, a modify, a rename or a delete; the other hooks are derived
110
+ * from it. `before` is null on an add, `after` on a delete, and on a rename
111
+ * their `dn` differ. A write that changed nothing fires none of them.
112
+ */
113
+ onLdapEntryChange?: (dn: string, before: Entry | null, after: Entry | null, context: ChangeContext) => MaybePromise<void>;
105
114
  onLdapChange?: (dn: string, changes: ChangesToNotify) => MaybePromise<void>;
106
115
  onLdapMailChange?: (dn: string, oldMail: AttributeValue | null, newMail: AttributeValue | null) => MaybePromise<void>;
107
116
  onLdapAliasChange?: (dn: string, mail: string, oldAliases: string[], newAliases: string[]) => MaybePromise<void>;
@@ -67,6 +67,20 @@ export default abstract class AuthzBase extends DmPlugin {
67
67
  * off — a relaxation in the default configuration.
68
68
  */
69
69
  protected effectiveBranch(dn: string): Promise<string>;
70
+ /**
71
+ * The DN of the entry an identity names, or null when none does.
72
+ *
73
+ * Searched under `server.ldap.base`, the base every other search uses:
74
+ * `--ldap-base`, which the server refuses to start without.
75
+ *
76
+ * More than one entry is a refusal, not a pick: which one comes first is
77
+ * the server's business, and differs between replicas. A failed search
78
+ * propagates rather than reading as "no such user", so a caller that
79
+ * caches the answer does not cache an outage as a fact.
80
+ *
81
+ * @throws AmbiguousIdentityError when several entries carry the identity
82
+ */
83
+ protected findUserDn(uid: string): Promise<string | null>;
70
84
  /**
71
85
  * Resolve user identifier to the format expected by getUserPermissions
72
86
  * This allows different implementations:
@@ -174,6 +188,18 @@ export default abstract class AuthzBase extends DmPlugin {
174
188
  * cheaper than working out which identity moved.
175
189
  */
176
190
  ldaprenamedone: () => void;
191
+ /**
192
+ * A delete removes the entry an identity resolves to, exactly as a
193
+ * rename moves it, so what was resolved is dropped the same way.
194
+ *
195
+ * An administrator deleted through the API is, for `authzLinid1`, still
196
+ * resolved to the former DN where the organization still names them:
197
+ * their session keeps its rights until the TTL runs out. Like the rename
198
+ * hook, the whole map goes rather than one key — the hook carries the
199
+ * deleted DN, and the cache keys identities, so a handful of lookups is
200
+ * cheaper than working out which identity the DN held.
201
+ */
202
+ ldapdeletedone: () => void;
177
203
  ldapmodifyrequest: ([dn, changes, opNumber, req]: [string, ModifyRequest, number, DmRequest?]) => Promise<[string, ModifyRequest, number, DmRequest?]>;
178
204
  ldapaddrequest: ([dn, entry, req]: [string, AttributesList, DmRequest?]) => Promise<[string, AttributesList, DmRequest?]>;
179
205
  ldapsearchrequest: ([base, opts, req]: [string, SearchOptions, DmRequest?]) => Promise<[string, SearchOptions, DmRequest?]>;
@@ -0,0 +1,24 @@
1
+ import type { Request } from 'express';
2
+ export interface ChangeContext {
3
+ /** The caller, under the name a person would use when there is one */
4
+ actor?: string;
5
+ /** Shared by every write of one request */
6
+ requestId?: string;
7
+ /** `rest`, `scim`, or what the plugin serving the request declared */
8
+ source?: string;
9
+ }
10
+ /**
11
+ * Declare the door a request came through, for the writes it makes.
12
+ *
13
+ * @param req the request
14
+ * @param source its name, e.g. `scim`
15
+ */
16
+ export declare function setChangeSource(req: Request, source: string): void;
17
+ /**
18
+ * The context of a write, handed to the "done" hooks.
19
+ *
20
+ * @param req the request behind the write; none for a scheduled task or a
21
+ * message consumer, whose context is empty
22
+ * @returns the context
23
+ */
24
+ export declare function changeContext(req?: Request): ChangeContext;
@@ -21,6 +21,21 @@ export declare class UnauthorizedError extends HttpError {
21
21
  export declare class ForbiddenError extends HttpError {
22
22
  constructor(message?: string);
23
23
  }
24
+ /**
25
+ * An identity that names more than one directory entry.
26
+ *
27
+ * A `uid` (or whatever `--ldap-user-main-attribute` names) is only unique if
28
+ * the directory enforces it, which OpenLDAP does not by default. Taking the
29
+ * first entry would hand the caller whichever one the server happens to list
30
+ * first — and with it that entry's groups or organizations. It is a refusal
31
+ * rather than a `null`: a `null` resolution is what
32
+ * `--authz-unresolved-user allow` lets through unchecked.
33
+ */
34
+ export declare class AmbiguousIdentityError extends ForbiddenError {
35
+ identity: string;
36
+ count: number;
37
+ constructor(identity: string, count: number);
38
+ }
24
39
  export declare class NotFoundError extends HttpError {
25
40
  constructor(message?: string);
26
41
  }
@@ -172,8 +172,9 @@ declare class ldapActions {
172
172
  * @param dn - Current DN (e.g., "uid=user1,ou=users,dc=example,dc=com")
173
173
  * @param newDn - Full new DN (e.g., "uid=user1,ou=trash,dc=example,dc=com")
174
174
  * ldapts will extract newRDN="uid=user1" and newSuperior="ou=trash,dc=example,dc=com"
175
+ * @param req - Request behind the move, when there is one
175
176
  */
176
- move(dn: string, newDn: string): Promise<boolean>;
177
+ move(dn: string, newDn: string, req?: Request): Promise<boolean>;
177
178
  delete(dn: string | string[], req?: Request): Promise<boolean>;
178
179
  private setDn;
179
180
  opNumber(): number;
@@ -23,6 +23,12 @@ export default class AuthzLinid1 extends AuthzBase {
23
23
  resolveUser(uid: string): Promise<string | null>;
24
24
  /**
25
25
  * Get user DN from uid
26
+ *
27
+ * An identity naming several entries is refused (`AmbiguousIdentityError`)
28
+ * rather than answered with `null`: under `--authz-unresolved-user allow`,
29
+ * `null` means "skip authorization", so an ambiguous directory would open
30
+ * every branch. A failed search propagates for the same reason — it used
31
+ * to read as `null` too, and `resolveCaller` then cached it.
26
32
  */
27
33
  getUserDn(uid: string): Promise<string | null>;
28
34
  /**
@@ -7,6 +7,7 @@
7
7
  * @group Plugins
8
8
  */
9
9
  import type { DM } from '../../bin';
10
+ import type { Hooks } from '../../hooks';
10
11
  import type { AuthConfig, BranchPermissions } from '../../config/args';
11
12
  import type { DmRequest } from '../../lib/auth/base';
12
13
  import AuthzBase from '../../lib/authz/base';
@@ -18,7 +19,25 @@ export default class AuthzPerBranch extends AuthzBase {
18
19
  name: string;
19
20
  authConfig?: AuthConfig;
20
21
  groupCache: Map<string, CachedGroups>;
22
+ /** Lookups under way, so concurrent misses for one uid share a search. */
23
+ private pendingGroups;
24
+ /**
25
+ * Bumped by every invalidation. A lookup that started before a write may
26
+ * have read the directory before it: it still answers its own callers,
27
+ * but does not put what it read back into the cache.
28
+ */
29
+ private groupGeneration;
30
+ /** `authConfig.groups` keyed by normalized DN, and the object it was built from. */
31
+ private groupRulesIndex?;
32
+ hooks: AuthzBase['hooks'] & Pick<Hooks, 'ldapadddone' | 'ldapmodifydone' | 'ldapdeletedone'>;
21
33
  constructor(server: DM);
34
+ /**
35
+ * Whether a modify can change which groups a uid belongs to: it touches
36
+ * the member attribute, or the attribute a uid is resolved by.
37
+ */
38
+ private touchesMembership;
39
+ /** Drop every cached and pending group lookup. */
40
+ forgetGroups(): void;
22
41
  /**
23
42
  * Resolve user - for authzPerBranch, we use uid directly
24
43
  */
@@ -62,9 +81,25 @@ export default class AuthzPerBranch extends AuthzBase {
62
81
  * Build LDAP filter for authorized branches
63
82
  */
64
83
  private buildBranchFilter;
84
+ /**
85
+ * The rules configured for a group, compared as DNs.
86
+ *
87
+ * The directory answers with its own spelling of a group's DN, and the
88
+ * configuration carries whatever an administrator typed: a textual lookup
89
+ * missed `cn=Admins,ou=Groups,…` against `cn=admins, ou=groups,…` and
90
+ * dropped the rule without a word. The index is rebuilt whenever
91
+ * `authConfig.groups` is replaced.
92
+ */
93
+ private groupRules;
65
94
  /**
66
95
  * Get user's group memberships with caching
96
+ *
97
+ * Concurrent misses for one uid share a single lookup. What the lookup
98
+ * found is cached, including "no group"; a failed search is not, so an
99
+ * outage does not outlive itself by a TTL.
67
100
  */
68
101
  getUserGroups(uid: string): Promise<string[]>;
102
+ /** Read a uid's group memberships from the directory. */
103
+ private lookupGroups;
69
104
  }
70
105
  export {};
@@ -87,7 +87,9 @@ export default class LdapGroups extends DmPlugin {
87
87
  schema?: Schema;
88
88
  constructor(server: DM);
89
89
  /**
90
- * Catch all deletion to remove deleted users from groups
90
+ * Remove deleted entries from groups once the delete has landed: a delete
91
+ * refused, failed or turned into something else by another plugin leaves
92
+ * the memberships alone.
91
93
  */
92
94
  hooks: Hooks;
93
95
  /**
@@ -123,14 +125,14 @@ export default class LdapGroups extends DmPlugin {
123
125
  /**
124
126
  * LDAP group methods
125
127
  */
126
- addGroup(cn: string, members?: string[], additional?: AttributesList): Promise<boolean>;
128
+ addGroup(cn: string, members?: string[], additional?: AttributesList, req?: Request): Promise<boolean>;
127
129
  modifyGroup(cn: string, changes: {
128
130
  add?: AttributesList;
129
131
  replace?: AttributesList;
130
132
  delete?: string[] | AttributesList;
131
- }): Promise<boolean>;
132
- renameGroup(cn: string, newCn: string): Promise<boolean>;
133
- deleteGroup(cn: string): Promise<boolean>;
133
+ }, req?: Request): Promise<boolean>;
134
+ renameGroup(cn: string, newCn: string, req?: Request): Promise<boolean>;
135
+ deleteGroup(cn: string, req?: Request): Promise<boolean>;
134
136
  addMember(cn: string, member: string | string[]): Promise<boolean>;
135
137
  deleteMember(cn: string, member: string): Promise<boolean>;
136
138
  deleteMemberFromAll(memberDn: string): Promise<void>;
@@ -1,33 +1,42 @@
1
1
  /**
2
2
  * @module core/onLdapChange
3
- * Check for ldap modify events and generate hooks:
4
- * - onLdapChange
5
- * - onLdapMailChange
3
+ * Publish the state of an entry before and after each write
4
+ * (onLdapEntryChange), and the hooks derived from it: onLdapChange,
5
+ * onLdapMailChange…
6
6
  * @author Xavier Guimard <xguimard@linagora.com>
7
7
  */
8
8
  import { Entry } from 'ldapts';
9
9
  import DmPlugin, { type Role } from '../../abstract/plugin';
10
10
  import type { Hooks } from '../../hooks';
11
11
  import type { AttributeValue } from '../../lib/ldapActions';
12
+ import type { ChangeContext } from '../../lib/changeContext';
12
13
  export type ChangesToNotify = Record<string, [
13
14
  AttributeValue | null,
14
15
  AttributeValue | null
15
16
  ]>;
17
+ /**
18
+ * The attributes whose values differ between two states of an entry. Values
19
+ * are compared as sets, so a single value and a one-element array are equal,
20
+ * and so are two orderings of the same values.
21
+ */
22
+ export declare function diffEntries(before: Entry | null, after: Entry | null): ChangesToNotify;
16
23
  declare class OnLdapChange extends DmPlugin {
17
24
  name: string;
18
25
  roles: Role[];
19
26
  stack: Record<number, Entry>;
20
27
  pendingDeletions: Map<string, Entry>;
28
+ pendingRenames: Map<string, Entry>;
21
29
  hooks: Hooks;
22
- notify(dn: string, changes: ChangesToNotify): void;
30
+ read(dn: string): Promise<Entry | undefined>;
31
+ publish(dn: string, before: Entry | null, after: Entry | null, context?: ChangeContext): void;
32
+ notify(dn: string, changes: ChangesToNotify, before: Entry | null, after: Entry | null): void;
23
33
  notifyAttributeChange(attribute: string, hookName: keyof Hooks, dn: string, changes: ChangesToNotify, stringOnly?: boolean): void;
24
- notifyAttributeChangeWithMail(attribute: string, hookName: keyof Hooks, dn: string, changes: ChangesToNotify): Promise<void>;
34
+ notifyAttributeChangeWithMail(attribute: string, hookName: keyof Hooks, dn: string, changes: ChangesToNotify, after: Entry | null): void;
25
35
  /**
26
36
  * Reconstruct display name from cn, givenName, and sn attributes
27
- * @param changes - The changes object
28
- * @param index - 0 for old value, 1 for new value
37
+ * @param entry - The entry, before or after the change
29
38
  * @returns The reconstructed display name or null
30
39
  */
31
- reconstructDisplayName(changes: ChangesToNotify, index: 0 | 1): string | null;
40
+ reconstructDisplayName(entry: Entry | null): string | null;
32
41
  }
33
42
  export default OnLdapChange;
@@ -214,7 +214,7 @@ export default class LdapOrganizations extends DmPlugin {
214
214
  */
215
215
  private linkedEntities;
216
216
  addOrganization(dn: string, entry: AttributesList, req?: Request): Promise<boolean>;
217
- modifyOrganization(dn: string, changes: ModifyRequest): Promise<boolean>;
217
+ modifyOrganization(dn: string, changes: ModifyRequest, req?: Request): Promise<boolean>;
218
218
  validateNewOrganization(dn: string, entry: AttributesList): Promise<boolean>;
219
219
  /**
220
220
  * Build the rejection message for a value that failed its schema `test`,
@@ -241,7 +241,7 @@ export default class LdapOrganizations extends DmPlugin {
241
241
  moveOrganization(dn: string, targetOrgDn: string, req?: Request): Promise<{
242
242
  newDn: string;
243
243
  }>;
244
- deleteOrganization(dn: string): Promise<boolean>;
244
+ deleteOrganization(dn: string, req?: Request): Promise<boolean>;
245
245
  /**
246
246
  * The same two searches as `getOrganisationSubnodes`, narrowed by a query.
247
247
  *
@@ -1283,24 +1283,7 @@ describe('NotificationPlugin', function () {
1283
1283
 
1284
1284
  ### Running Tests
1285
1285
 
1286
- ```bash
1287
- # Set up test environment
1288
- cat > ~/.test-env <<EOF
1289
- export DM_LDAP_URL="ldap://localhost:389"
1290
- export DM_LDAP_DN="cn=admin,dc=example,dc=com"
1291
- export DM_LDAP_PWD="admin"
1292
- export DM_LDAP_BASE="dc=example,dc=com"
1293
- EOF
1294
-
1295
- # Run all tests
1296
- source ~/.test-env && npm test
1297
-
1298
- # Run specific test file
1299
- source ~/.test-env && npm run test:one test/plugins/notification/webhook.test.ts
1300
-
1301
- # Run tests in watch mode
1302
- source ~/.test-env && npm run test:dev
1303
- ```
1286
+ See [Tests and checks](../../CONTRIBUTING.md#tests-and-checks).
1304
1287
 
1305
1288
  ### Test Best Practices
1306
1289
 
@@ -16,3 +16,9 @@ Typescript definitions into [hooks.ts](./src/hooks.ts)
16
16
  - **ldapmodifyrequest**: called before any ldapmodify
17
17
  - **ldapdeleterequest**: called before any ldapdelete
18
18
  - **ldaprenamerequest**: called before any ldap rename/modifyDN operation
19
+
20
+ ## [onChange](../usage/plugins/ldap/on-change.md) hooks
21
+
22
+ - **onLdapEntryChange**: called after any write, with the entry before and
23
+ after it, and who made it
24
+ - **onLdapChange**, **onLdapMailChange**…: derived from it
@@ -76,23 +76,7 @@ Loading initial LDIF from .../base-structure.ldif...
76
76
 
77
77
  ### Running Tests
78
78
 
79
- **Using embedded LDAP (no setup required):**
80
-
81
- ```bash
82
- npm test
83
- npm run test:one test/plugins/ldap/flatGeneric.test.ts
84
- ```
85
-
86
- **Using external LDAP:**
87
-
88
- ```bash
89
- export DM_LDAP_URL=ldap://localhost:389
90
- export DM_LDAP_DN=cn=admin,dc=example,dc=com
91
- export DM_LDAP_PWD=secret
92
- export DM_LDAP_BASE=dc=example,dc=com
93
-
94
- npm test
95
- ```
79
+ See [Tests and checks](../../CONTRIBUTING.md#tests-and-checks).
96
80
 
97
81
  ### In CI/CD
98
82
 
@@ -101,7 +101,7 @@ export DM_PLUGINS="core/auth/token,core/ldap/flatGeneric,core/ldap/groups"
101
101
  | `--ldap-url` | `--ldap-urls` | `DM_LDAP_URL` | `ldap://localhost` | LDAP server URL(s) |
102
102
  | `--ldap-dn` | | `DM_LDAP_DN` | `cn=admin,dc=example,dc=com` | Bind DN |
103
103
  | `--ldap-pwd` | | `DM_LDAP_PWD` | `admin` | Password |
104
- | `--ldap-base` | | `DM_LDAP_BASE` | | Base DN for searches |
104
+ | `--ldap-base` | | `DM_LDAP_BASE` | | Base DN for searches (required) |
105
105
  | `--ldap-user-main-attribute` | | `DM_LDAP_USER_ATTRIBUTE` | `uid` | User identifier attribute |
106
106
  | `--ldap-cache-max` | | `DM_LDAP_CACHE_MAX` | `1000` | Max cache entries |
107
107
  | `--ldap-cache-ttl` | | `DM_LDAP_CACHE_TTL` | `0` | Search cache TTL (seconds), `0` disables it |
@@ -75,19 +75,20 @@ works behind an authenticator that publishes what the directory calls a user:
75
75
  | `core/auth/openidconnect` | the OIDC `sub` | **no** — an opaque provider identifier |
76
76
 
77
77
  An identity that does not resolve is **refused** (403), and the refusal is
78
- logged with the plugin's name. Until 0.9.0 it was let through instead, which
79
- made this pairing an open door rather than a misconfiguration; see
80
- [the note](../../upgrading.md#an-identity-that-does-not-resolve-is-refused)
81
- and `--authz-unresolved-user` for the transition.
78
+ logged with the plugin's name; `--authz-unresolved-user` sets it otherwise
79
+ ([notes](../../upgrading.md#an-identity-that-does-not-resolve-is-refused)).
80
+ An identity naming **several** entries is refused (403) whatever that
81
+ option says, and a warning gives the number of entries. A lookup the
82
+ directory fails fails the request and is not cached.
82
83
 
83
84
  Both answers are cached for the plugin's TTL (5 minutes), negatives
84
85
  included, so a mismatch does not turn every request into a directory search.
85
86
  The TTL cuts both ways: an identity added to the directory after being
86
87
  refused waits it out, and an administrator whose **own entry is renamed or
87
88
  moved** keeps resolving to the former DN — where no organization names them,
88
- so their own operations are refused. A rename or a delete performed through
89
- this server drops what was resolved; a change made directly in the directory
90
- waits out the TTL.
89
+ so their own operations are refused. A rename, a move to the trash or a
90
+ delete performed through this server drops what was resolved; a change made
91
+ directly in the directory waits out the TTL.
91
92
 
92
93
  `GET /api/v1/authz/scope` resolves the identity outside that cache, so
93
94
  during such a window it can describe a scope the hooks no longer grant.