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.
- package/bin/index.mjs +1 -1
- package/dist/abstract/ldapFlat.js +1 -1
- package/dist/lib/authz/base.js +1 -1
- package/dist/lib/changeContext.js +1 -0
- package/dist/lib/errors.js +1 -1
- package/dist/lib/ldapActions.js +1 -1
- package/dist/plugins/auth/authzLinid1.js +1 -1
- package/dist/plugins/auth/authzPerBranch.js +1 -1
- package/dist/plugins/ldap/departmentSync.js +1 -1
- package/dist/plugins/ldap/groups.js +1 -1
- package/dist/plugins/ldap/onChange.js +1 -1
- package/dist/plugins/ldap/organizations.js +1 -1
- package/dist/plugins/ldap/trash.js +1 -1
- package/dist/plugins/scim/scim.js +1 -1
- package/dist/plugins/twake/appAccountsApi.js +1 -1
- package/dist/plugins/twake/appAccountsConsistency.js +1 -1
- package/dist/plugins/twake/clouderyProvision.js +1 -1
- package/dist/plugins/twake/cozyProvision.js +1 -1
- package/dist/src/abstract/plugin.d.ts +6 -0
- package/dist/src/hooks.d.ts +15 -6
- package/dist/src/lib/authz/base.d.ts +26 -0
- package/dist/src/lib/changeContext.d.ts +24 -0
- package/dist/src/lib/errors.d.ts +15 -0
- package/dist/src/lib/ldapActions.d.ts +2 -1
- package/dist/src/plugins/auth/authzLinid1.d.ts +6 -0
- package/dist/src/plugins/auth/authzPerBranch.d.ts +35 -0
- package/dist/src/plugins/ldap/groups.d.ts +7 -5
- package/dist/src/plugins/ldap/onChange.d.ts +17 -8
- package/dist/src/plugins/ldap/organizations.d.ts +2 -2
- package/docs/plugin-development/README.md +1 -18
- package/docs/plugin-development/hooks.md +6 -0
- package/docs/plugin-development/testing.md +1 -17
- package/docs/usage/configuration.md +1 -1
- package/docs/usage/plugins/auth/authz-linid1.md +8 -7
- package/docs/usage/plugins/auth/authz-per-branch.md +33 -11
- package/docs/usage/plugins/auth/authz-scope.md +5 -0
- package/docs/usage/plugins/ldap/on-change.md +51 -284
- package/docs/usage/troubleshooting.md +1 -9
- package/docs/usage/upgrading.md +52 -0
- 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,
|
|
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
|
|
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
|
/**
|
package/dist/src/hooks.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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;
|
package/dist/src/lib/errors.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
-
|
|
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):
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
and
|
|
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
|
|
89
|
-
this server drops what was resolved; a change made
|
|
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.
|