ldap-rest 1.1.0 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/index.mjs +8 -7
- package/dist/abstract/ldapFlat.js +1 -1
- package/dist/abstract/plugin.js +1 -1
- package/dist/abstract/twakePlugin.js +1 -1
- package/dist/bin/index.js +1 -1
- package/dist/config/args.js +1 -1
- package/dist/config/schema.js +1 -1
- package/dist/lib/changeContext.js +1 -1
- package/dist/lib/entitySchema/validator.js +1 -0
- package/dist/lib/expressFormatedResponses.js +1 -1
- package/dist/lib/health.js +1 -0
- package/dist/lib/instanceProviders.js +1 -1
- package/dist/lib/ldapActions.js +1 -1
- package/dist/lib/ldapSchema.js +1 -1
- package/dist/lib/metrics.js +1 -0
- package/dist/lib/requestContext.js +1 -1
- package/dist/lib/shutdown.js +1 -0
- package/dist/lib/staticFiles.js +1 -0
- package/dist/lib/storage/store.js +1 -1
- package/dist/lib/utils.js +1 -1
- package/dist/logger/winston.js +1 -1
- package/dist/plugins/auth/crowdsec.js +1 -1
- package/dist/plugins/auth/openidconnect.js +1 -1
- package/dist/plugins/demo/helloworld.js +1 -1
- package/dist/plugins/ldap/bulkImport.js +1 -1
- package/dist/plugins/ldap/departmentSync.js +1 -1
- package/dist/plugins/ldap/flatGeneric.js +1 -1
- package/dist/plugins/ldap/groups.js +1 -1
- package/dist/plugins/ldap/organizations.js +1 -1
- package/dist/plugins/metrics.js +1 -0
- package/dist/plugins/rabbitmq.js +1 -1
- package/dist/plugins/scim/baseResolver.js +1 -1
- package/dist/plugins/scim/bulk.js +1 -1
- package/dist/plugins/scim/errors.js +1 -1
- package/dist/plugins/scim/filter.js +1 -1
- package/dist/plugins/scim/groups.js +1 -1
- package/dist/plugins/scim/mapping.js +1 -1
- package/dist/plugins/scim/patch.js +1 -1
- package/dist/plugins/scim/projection.js +1 -1
- package/dist/plugins/scim/scim.js +1 -1
- package/dist/plugins/scim/users.js +1 -1
- package/dist/plugins/static.js +1 -1
- package/dist/plugins/storage/file.js +1 -1
- package/dist/plugins/storage/postgres.js +1 -1
- package/dist/plugins/storage/valkey.js +1 -1
- package/dist/plugins/storage.js +1 -1
- package/dist/plugins/twake/calendar.js +1 -1
- package/dist/plugins/twake/clouderyProvision.js +1 -1
- package/dist/plugins/twake/cozyProvision.js +1 -1
- package/dist/plugins/twake/drive.js +1 -1
- package/dist/plugins/twake/groups.js +1 -1
- package/dist/plugins/twake/james.js +1 -1
- package/dist/plugins/twake/migrationProxy.js +1 -0
- package/dist/plugins/twake/spaces.js +1 -1
- package/dist/plugins/weblogs.js +1 -1
- package/dist/src/abstract/ldapFlat.d.ts +3 -13
- package/dist/src/abstract/plugin.d.ts +20 -0
- package/dist/src/abstract/twakePlugin.d.ts +23 -1
- package/dist/src/bin/index.d.ts +46 -1
- package/dist/src/config/args.d.ts +12 -1
- package/dist/src/lib/changeContext.d.ts +11 -0
- package/dist/src/lib/entitySchema/validator.d.ts +64 -0
- package/dist/src/lib/expressFormatedResponses.d.ts +19 -0
- package/dist/src/lib/health.d.ts +63 -0
- package/dist/src/lib/instanceProviders.d.ts +4 -0
- package/dist/src/lib/ldapActions.d.ts +32 -2
- package/dist/src/lib/metrics.d.ts +44 -0
- package/dist/src/lib/requestContext.d.ts +40 -0
- package/dist/src/lib/shutdown.d.ts +20 -0
- package/dist/src/lib/staticFiles.d.ts +33 -0
- package/dist/src/lib/storage/store.d.ts +8 -0
- package/dist/src/lib/utils.d.ts +15 -0
- package/dist/src/logger/winston.d.ts +4 -0
- package/dist/src/plugins/ldap/groups.d.ts +18 -3
- package/dist/src/plugins/ldap/organizations.d.ts +3 -5
- package/dist/src/plugins/metrics.d.ts +17 -0
- package/dist/src/plugins/rabbitmq.d.ts +37 -1
- package/dist/src/plugins/scim/errors.d.ts +2 -0
- package/dist/src/plugins/scim/types.d.ts +2 -1
- package/dist/src/plugins/static.d.ts +12 -0
- package/dist/src/plugins/storage/file.d.ts +4 -0
- package/dist/src/plugins/storage/postgres.d.ts +7 -14
- package/dist/src/plugins/storage/valkey.d.ts +2 -15
- package/dist/src/plugins/storage.d.ts +8 -0
- package/dist/src/plugins/twake/drive.d.ts +8 -0
- package/dist/src/plugins/twake/groups.d.ts +1 -2
- package/dist/src/plugins/twake/james.d.ts +2 -0
- package/dist/src/plugins/twake/migrationProxy.d.ts +71 -0
- package/docs/client-development/api/reference.md +5 -9
- package/docs/client-development/api/rest-api.md +4 -10
- package/docs/client-development/schemas/README.md +60 -21
- package/docs/plugin-development/README.md +15 -13
- package/docs/plugin-development/dependencies.md +8 -0
- package/docs/plugin-development/hooks.md +1 -1
- package/docs/usage/README.md +1 -0
- package/docs/usage/configuration.md +62 -24
- package/docs/usage/operations.md +193 -0
- package/docs/usage/plugins/README.md +2 -0
- package/docs/usage/plugins/auth/authz-per-branch.md +2 -1
- package/docs/usage/plugins/integrations/README.md +8 -4
- package/docs/usage/plugins/integrations/groups.md +2 -2
- package/docs/usage/plugins/integrations/mail-migration.md +102 -0
- package/docs/usage/plugins/integrations/scim.md +16 -3
- package/docs/usage/plugins/integrations/spaces.md +1 -1
- package/docs/usage/plugins/ldap/bulk-import.md +3 -1
- package/docs/usage/plugins/ldap/flat-generic.md +5 -2
- package/docs/usage/plugins/ldap/on-change.md +2 -1
- package/docs/usage/plugins/ldap/organizations.md +7 -7
- package/docs/usage/plugins/utilities/metrics.md +21 -0
- package/docs/usage/plugins/utilities/static.md +16 -8
- package/docs/usage/plugins/utilities/storage.md +7 -0
- package/docs/usage/plugins/utilities/weblogs.md +51 -30
- package/docs/usage/upgrading.md +84 -0
- package/package.json +11 -2
- package/static/schemas/example/README.md +1 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
import
|
|
1
|
+
import{TwakePlugin as t}from"../../abstract/twakePlugin.js";class Drive extends t{constructor(t){super(t,"twake_drive_webadmin_url","twake_drive_webadmin_token","twake_drive_concurrency"),this.name="drive",this.roles=["consistency"],this.dependencies={onLdapChange:"core/ldap/onChange"},this.hooks={onLdapMailChange:async(t,e,i)=>{const n=e?Array.isArray(e)?String(e[0]):String(e):null,a=i?Array.isArray(i)?String(i[0]):String(i):null;if(!n)return void this.logger.debug(`Skipping mail change for ${t}: oldmail is empty (mail attribute was added, not changed)`);if(!a)return void this.logger.debug(`Skipping mail change for ${t}: newmail is empty (mail attribute was deleted)`);const r=await this.getCozyDomain(t);r?await this.updateCozyInstance("onLdapMailChange",r,t,{Email:a}):this.logger.debug(`Skipping mail change for ${t}: no Cozy domain attribute (${this.cozyDomainAttr})`)},onLdapDisplayNameChange:async(t,e,i)=>{const n=await this.ldapGetAttributes(t,[this.cozyDomainAttr,this.displayNameAttr,"cn","givenName","sn"]);if(!n)return void this.logger.warn(`Cannot update Cozy display name: entry not found for ${t}`);const a=this.attributeToString(n[this.cozyDomainAttr]);if(!a)return void this.logger.debug(`Skipping display name change for ${t}: no Cozy domain attribute (${this.cozyDomainAttr})`);const r=this.getDisplayNameFromAttributes(n);r?await this.updateCozyInstance("onLdapDisplayNameChange",a,t,{PublicName:r}):this.logger.warn(`Cannot update Cozy display name: no display name found for ${t}`)},onLdapDriveQuotaChange:async(t,e,i)=>{if(null===i)return void this.logger.debug(`Skipping drive quota change for ${t}: quota was deleted`);const n=await this.getCozyDomain(t);n?await this.updateCozyInstance("onLdapDriveQuotaChange",n,t,{DiskQuota:String(i)}):this.logger.debug(`Skipping drive quota change for ${t}: no Twake Drive domain attribute (${this.cozyDomainAttr})`)}},this.cozyDomainAttr=this.config.twake_drive_domain_attribute||"twakeCozyDomain",this.defaultDomainTemplate=this.config.twake_drive_default_domain_template||"",this.templateAttrs=this.defaultDomainTemplate?[...this.defaultDomainTemplate.matchAll(/\{(\w+)\}/g)].map(t=>t[1]):[],this.driveQuotaAttr=this.config.drive_quota_attribute||"twakeDriveQuota"}createHeaders(t){const e={};if(this.webadminToken){const t=Buffer.from(`:${this.webadminToken}`).toString("base64");e.Authorization=`Basic ${t}`}return t&&(e["Content-Type"]=t),e}isValidCozyDomain(t){if(!t||t.length>253)return!1;if(t.includes("/")||t.includes("\\")||t.includes("?")||t.includes("#"))return!1;return/^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/.test(t)}async getCozyDomain(t){const e=[this.cozyDomainAttr,...this.templateAttrs],i=await this.ldapGetAttributes(t,e),n=i?this.attributeToString(i[this.cozyDomainAttr]):null;if(n)return n;if(this.defaultDomainTemplate&&i){let t=this.defaultDomainTemplate;for(const e of this.templateAttrs){const n=this.attributeToString(i[e]);if(!n)return null;t=t.replace(`{${e}}`,()=>n)}return t}return null}async updateCozyInstance(t,e,i,n){if(!this.isValidCozyDomain(e))return void this.logger.error({plugin:this.name,event:t,result:"error",dn:i,cozyDomain:e,error:"Invalid Cozy domain format (potential URL injection attempt)"});const a=new URL(`${this.webadminUrl}/instances/${e}`);a.searchParams.set("FromCloudery","true");for(const[t,e]of Object.entries(n))a.searchParams.set(t,e);return this.requestLimit(async()=>{const r={plugin:this.name,event:t,result:"error",dn:i,cozyDomain:e,...n};try{const t=await this.fetch(a.toString(),{method:"PATCH",headers:this.createHeaders("application/json")});t.ok?this.logger.info({...r,result:"success",http_status:t.status}):404===t.status?this.logger.debug({...r,result:"ignored",http_status:t.status,http_status_text:t.statusText,message:"Cozy instance not found (may not be provisioned yet)"}):this.logger.error({...r,http_status:t.status,http_status_text:t.statusText,url:a.toString()})}catch(t){this.logger.error({...r,error:t,url:a.toString()})}})}getDisplayNameFromAttributes(t){const e=this.attributeToString(t[this.displayNameAttr]);if(e)return e;const i=this.attributeToString(t.cn);if(i)return i;const n=this.attributeToString(t.givenName),a=this.attributeToString(t.sn);if(n||a){const t=[];return n&&t.push(n),a&&t.push(a),t.join(" ")}return null}async getDisplayNameFromDN(t){const e=[this.displayNameAttr,"cn","givenName","sn"],i=await this.ldapGetAttributes(t,e);return i?this.getDisplayNameFromAttributes(i):null}async getMailFromDN(t){const e=await this.ldapGetAttributes(t,[this.mailAttr]);return e?this.attributeToString(e[this.mailAttr]):null}async getDriveQuotaFromDN(t){const e=await this.ldapGetAttributes(t,[this.driveQuotaAttr]);if(!e)return null;const i=this.attributeToString(e[this.driveQuotaAttr]);return i?Number(i):null}async syncUserToCozy(t){const e=await this.ldapGetAttributes(t,[this.cozyDomainAttr,this.mailAttr,this.displayNameAttr,this.driveQuotaAttr,"cn","givenName","sn"]);if(!e)return this.logger.error(`Cannot sync user: entry not found for ${t}`),!1;const i=this.attributeToString(e[this.cozyDomainAttr]);if(!i)return this.logger.error(`Cannot sync user: no Twake Drive domain attribute for ${t}`),!1;const n=this.attributeToString(e[this.mailAttr]),a=this.getDisplayNameFromAttributes(e),r=this.attributeToString(e[this.driveQuotaAttr]),s={};return n&&(s.Email=n),a&&(s.PublicName=a),r&&(s.DiskQuota=r),0===Object.keys(s).length?(this.logger.warn(`No attributes to sync for ${t}`),!1):(await this.updateCozyInstance("syncUserToCozy",i,t,s),!0)}async blockInstance(t,e){const i=await this.getCozyDomain(t);if(!i)return this.logger.error(`Cannot block instance: no Cozy domain for ${t}`),!1;const n={Blocked:"true"};return e&&(n.BlockingReason=e),await this.updateCozyInstance("blockInstance",i,t,n),!0}async unblockInstance(t){const e=await this.getCozyDomain(t);return e?(await this.updateCozyInstance("unblockInstance",e,t,{Blocked:"false"}),!0):(this.logger.error(`Cannot unblock instance: no Cozy domain for ${t}`),!1)}async deleteInstance(t){const e=await this.getCozyDomain(t);if(!e)return this.logger.error(`Cannot delete instance: no Cozy domain for ${t}`),!1;if(!this.isValidCozyDomain(e))return this.logger.error({plugin:this.name,event:"deleteInstance",result:"error",dn:t,cozyDomain:e,error:"Invalid Cozy domain format (potential URL injection attempt)"}),!1;const i=`${this.webadminUrl}/instances/${e}`;return this.requestLimit(async()=>{try{const n=await this.fetch(i,{method:"DELETE",headers:this.createHeaders()});return n.ok?(this.logger.info({plugin:this.name,event:"deleteInstance",result:"success",dn:t,cozyDomain:e,http_status:n.status}),!0):404===n.status?(this.logger.info({plugin:this.name,event:"deleteInstance",result:"ignored",dn:t,cozyDomain:e,http_status:404,message:"Cozy instance not found (already deleted or never created)"}),!0):(this.logger.error({plugin:this.name,event:"deleteInstance",result:"error",dn:t,cozyDomain:e,http_status:n.status,http_status_text:n.statusText,url:i}),!1)}catch(n){return this.logger.error({plugin:this.name,event:"deleteInstance",result:"error",dn:t,cozyDomain:e,error:n,url:i}),!1}})}}export{Drive as default};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{randomUUID as t}from"node:crypto";import{BadRequestError as e,AUTHZ_REFUSED as r,HttpError as s}from"../../lib/errors.js";import{extractLdapCode as i}from"../../lib/ldapCodes.js";import{parseDn as o,escapeDnValue as a,isDummyMemberDn as n,escapeLdapFilter as c,isAuthzRefusal as u,normalizeDn as d,unescapeDnValue as h}from"../../lib/utils.js";import p from"../ldap/groups.js";import{BaseResolver as m}from"../scim/baseResolver.js";import{mergeMapping as g,loadMappingFile as l,DEFAULT_GROUP_MAPPING as f}from"../scim/mapping.js";import{outsideRequest as y}from"../../lib/requestContext.js";import{valueOf as w,lifecycleAttributes as b,first as _}from"./lifecycleAttributes.js";const read=(t,e)=>_(w(t,e)),v="{org}",O=["displayName","description","createdAt"],$=["uid","displayName","mail","jobTitle"],k={uid:"uid",_id:"entryUUID",cn:"cn",sn:"sn",givenName:"givenName",displayName:"displayName",mail:"mail",mobile:"mobile",jobTitle:"twakeJobTitle",company:"twakeCompany",organizationRole:"twakeOrganizationRole",organizationId:"twakeOrganizationId"},I={familyName:"sn",givenName:"givenName",additionalName:"twakeAdditionalName",namePrefix:"twakeNamePrefix"},j="twakeIsTechnical";class RouteError extends Error{constructor(t,e,r){super(e),this.status=t,this.code=r}}const notFound=t=>new RouteError(404,`${t[0].toUpperCase()}${t.slice(1)} not found`,`${t.toUpperCase()}_NOT_FOUND`),invalid=(t,e="INVALID_INPUT")=>new RouteError(400,t,e),N="name must be a non-blank string of at most 256 characters with no control characters";function isDisplayName(t){return"string"==typeof t&&t.trim().length>0&&t.length<=256&&!/[\x00-\x1f\x7f]/.test(t)}const R=/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/,isColor=t=>"string"==typeof t&&R.test(t),text=t=>"string"==typeof t||"number"==typeof t?String(t):"";function bodyOf(t){const e=t.body;if(!e||"object"!=typeof e||Array.isArray(e))throw invalid("Request body must be a JSON object");return e}function values(t){return null==t?[]:(Array.isArray(t)?t:[t]).map(t=>t.toString())}function dnKey(t){try{return d(t)}catch{return t.toLowerCase()}}function parentOf(t){return o(t).slice(1).join(",")}function rdnValue(t){const[e]=o(t),r=e.indexOf("=");return r>0?h(e.slice(r+1).trim()):t}function branchPattern(t){const[e,r]=o(t).join(",").split(v).map(t=>t.replace(/[.*+?^${}()|[\]\\]/g,"\\$&"));return new RegExp(`^${e}(?<org>(?:\\\\.|[^,])+)${r}$`,"i")}function movedInto(t,e,r){const s=organizationIn(t,r);return void 0!==s&&dnKey(parentOf(r))!==dnKey(parentOf(e))?s:void 0}const P=["twakegroup","twakestaticgroup"],z=Symbol("whole member lists");function organizationIn(t,e){const r=t.exec(parentOf(o(e).join(",")));return r?.groups?h(r.groups.org):void 0}class TwakeGroups extends p{constructor(r){super(r),this.name="twakeGroups",this.hooks={ldapaddrequest:([t,e,r])=>{this.checkMembers(t,e.member);const s=this.organizationOf(t);return void 0!==s&&void 0!==w(e,this.displayName)&&void 0===w(e,this.createdAt)&&(e={...e,[this.createdAt]:(new Date).toISOString()}),void 0!==s&&(e=this.linked(t,s,e)),[t,e,r]},ldapmodifyrequest:([t,e,r,s])=>(this.checkMembers(t,e.add?.member),this.checkMembers(t,e.replace?.member),this.checkLinkChange(t,e),[t,e,r,s]),ldaprenamerequest:async([t,r,s])=>{const o=movedInto(this.groupPattern,t,r);if(void 0===o)return[t,r,s];let a;try{const{searchEntries:e}=await this.ldap.system.search({paged:!1,scope:"base",attributes:["member"],[z]:!0},t);a=e[0]?.member}catch(t){if(32!==i(t))throw t}if(void 0!==this.foreign(o,a))throw new e(`The group holds members of another organization than ${o}`);return[t,r,s]},ldaprenamedone:async([t,e])=>{const r=movedInto(this.groupPattern,t,e),s=this.config.ldap_organization_link_attribute;if(void 0===r||!s||!this.orgPattern||!this.judgedByAttachment())return;const{searchEntries:i}=await this.ldap.system.search({paged:!1,scope:"base",attributes:[s]},e);void 0!==w(i[0]??{},s)&&await this.ldap.system.modify(e,{replace:{[s]:this.orgPattern.replace(v,a(r))}})},ldapsearchfilter:async([t,e,r])=>[r?.[z]?t:await this.hideTombstones(t),e,r],scimgroupcreate:([e,r,s])=>(s??(s=new m(this.config).groupBase(r)),[this.groupPattern.test(o(s).join(","))?{...e,id:t()}:e,r,s])},this.groupBase=this.config.twake_group_base||"",this.userBase=this.config.twake_group_user_base||"";for(const[t,e]of[["--twake-group-base",this.groupBase],["--twake-group-user-base",this.userBase]])if(!e.includes(v))throw new Error(`${this.name}: ${t} must hold ${v}`);this.groupPattern=branchPattern(this.groupBase),this.attrs=b(this.config),this.orgPattern=this.config.twake_group_organization_dn||"",this.displayName=this.config.twake_group_display_name_attribute||"twakeDisplayName",this.color=this.config.twake_group_color_attribute||"twakeGroupColor",this.createdAt=this.config.twake_group_created_at_attribute||"twakeCreatedAt",this.maxPage=this.config.twake_group_max_page_limit||1e3,this.memberFields={...k,...this.config.twake_group_member_fields}}adaptSchema(t){const e={[this.displayName]:{type:"string",required:!0},[this.color]:{type:"string",test:R.source},[this.createdAt]:{type:"string"}},r=this.config.ldap_organization_link_attribute;r&&this.orgPattern&&(e[r]={type:"string",generated:!0});for(const[r,s]of Object.entries(e)){const e=Object.keys(t.attributes).find(t=>t.toLowerCase()===r.toLowerCase()),i=e?t.attributes[e]:s;e&&delete t.attributes[e],t.attributes[r]=i}return t}afterLoad(){if(!this.server.loadedPlugins.scim)return;const t=this.config.scim_group_rdn_attribute||"cn",e=this.config.scim_group_mapping||"",r=g(f,e?l(e):void 0).entries.find(e=>e.ldap?.toLowerCase()===t.toLowerCase());r&&this.logger.warn(`${this.name}: the SCIM group mapping writes ${r.scim} to ${t}, so SCIM groups keep it as their RDN instead of a generated cn: map ${r.scim} to another attribute with --scim-group-mapping`)}linked(t,r,s){const i=this.config.ldap_organization_link_attribute;if(!i||!this.orgPattern||!this.judgedByAttachment())return s;const o=this.orgPattern.replace(v,a(r)),n=read(s,i);if(void 0!==n){if(dnKey(n)!==dnKey(o))throw new e(`${t} lies in the branch of ${r}, and must be linked to it`);return s}const c=values(s.objectClass);return c.some(t=>P.includes(t.toLowerCase()))||c.push("twakeGroup"),{...s,objectClass:c,[i]:o}}checkLinkChange(t,r){const s=this.config.ldap_organization_link_attribute,i=this.organizationOf(t);if(void 0===i||!s||!this.orgPattern||!this.judgedByAttachment())return;const o=dnKey(this.orgPattern.replace(v,a(i))),named=t=>Object.keys(t??{}).filter(t=>t.toLowerCase()===s.toLowerCase()),n=[...named(r.add).flatMap(t=>values(r.add[t])),...named(r.replace).flatMap(t=>values(r.replace[t]))],c=Array.isArray(r.delete)?r.delete.some(t=>t.toLowerCase()===s.toLowerCase()):named(r.delete).length>0,u=named(r.replace).length>0&&0===n.length;if(c||u||n.some(t=>dnKey(t)!==o))throw new e(`${t} lies in the branch of ${i}, and must stay linked to it`)}judgedByAttachment(){return Object.values(this.server.loadedPlugins).some(t=>t.roles?.includes("authz")&&!0===t.judgesByAttachment?.())}organizationOf(t){return organizationIn(this.groupPattern,t)}groupBaseOf(t){return this.groupBase.replace(v,a(t))}userBaseOf(t){return this.userBase.replace(v,a(t))}checkMembers(t,r){const s=this.organizationOf(t);if(!s)return;const i=this.foreign(s,r);if(void 0!==i)throw new e(`${i} is not a user of organization ${s}`)}foreign(t,e){const r=dnKey(this.userBaseOf(t));return values(e).find(t=>!n(t,this.config.group_dummy_user)&&dnKey(parentOf(t))!==r)}async hideTombstones(t){if(!this.attrs.deleted)return t;const e=new Set;for(const r of t.searchEntries){const t=this.organizationOf(r.dn);t&&void 0!==r.member&&e.add(t)}if(0===e.size)return t;const r=new Set;for(const t of e)for(const e of await this.tombstonesOf(t))r.add(dnKey(e));return 0===r.size?t:{...t,searchEntries:t.searchEntries.map(t=>void 0!==t.member&&this.organizationOf(t.dn)?{...t,member:values(t.member).filter(t=>!r.has(dnKey(t)))}:t)}}async tombstonesOf(t){const{deleted:e,deletedValue:r}=this.attrs;if(!e)return[];try{const{searchEntries:s}=await this.ldap.system.search({paged:!1,scope:"one",filter:`(${e}=${c(r)})`,attributes:["dn"]},this.userBaseOf(t));return s.map(t=>t.dn)}catch(t){if(32===i(t))return[];throw t}}api(t){const e=`${this.config.api_prefix}/v1/organizations/:id/groups`,r=this.organizationRoute.bind(this);t.get(e,r("listing groups",(t,e,r)=>this.listRoute(t,e,r))),t.post(e,r("creating group",(t,e,r)=>this.createRoute(t,e,r))),t.get(`${e}/:groupId`,r("getting group",async(t,e,r)=>{const s=await this.readGroup(r,t.params.groupId,t);if(!s)throw notFound("group");e.json(s)})),t.get(`${e}/:groupId/members`,r("listing group members",(t,e,r)=>this.membersRoute(t,e,r))),t.patch(`${e}/:groupId`,r("updating group",(t,e,r)=>this.updateRoute(t,e,r))),t.delete(`${e}/:groupId`,r("deleting group",async(t,e,r)=>{await this.onGroup(()=>this.deleteGroup(this.groupDn(r,t.params.groupId),t)),e.json({success:!0})})),t.post(`${e}/:groupId/members`,r("adding group members",(t,e,r)=>this.addMembersRoute(t,e,r))),t.delete(`${e}/:groupId/members/:userId`,r("removing group member",(t,e,r)=>this.removeMemberRoute(t,e,r)))}organizationRoute(t,e,i=this.name){return async(o,a)=>{try{const t=o.params.id;await this.checkOrganization(t,o),await e(o,a,t)}catch(e){if(e instanceof RouteError)return void a.status(e.status).json({error:e.message,code:e.code});if(u(e))return void a.status(403).json({error:r,code:"REFUSED"});if(e instanceof s&&e.statusCode<500)return void a.status(e.statusCode).json({error:e.message,code:400===e.statusCode?"INVALID_INPUT":"REFUSED"});this.logger.error({plugin:i,context:t,error:String(e)}),a.status(500).json({error:`Error ${t}`,code:"INTERNAL_ERROR"})}}}groupDn(t,e){return`${this.cn}=${a(e)},${this.groupBaseOf(t)}`}get userAttribute(){return this.config.ldap_user_main_attribute||"uid"}userDn(t,e){return`${this.userAttribute}=${a(e)},${this.userBaseOf(t)}`}async checkOrganization(t,e){if(!this.orgPattern)return;const r=this.config.twake_group_organization_status_attribute||"twakeOrgStatus",s=this.orgPattern.replace(v,a(t));if(!await this.ldap.readableBy(s,e))throw notFound("organization");let o;try{o=(await this.ldap.system.search({paged:!1,scope:"base",attributes:[r]},s)).searchEntries[0]}catch(t){if(32!==i(t))throw t}if(!o)throw notFound("organization");const n=this.config.twake_group_organization_deleted_value||"deleted";if(read(o,r)===n)throw new RouteError(410,"Organization has been deleted","ORGANIZATION_DELETED")}page(t,e){const r=Math.max(1,parseInt(t.query.page)||1),s=Math.max(1,Math.min(parseInt(t.query.limit)||20,this.maxPage)),i=t.query.search||void 0;if(void 0!==i&&("string"!=typeof i||i.length<2))throw invalid("Search query must be at least 2 characters","INVALID_SEARCH_QUERY");const o=t.query.sortBy;if(void 0!==o&&!e.includes(o))throw invalid(`sortBy must be one of: ${e.join(", ")}`,"INVALID_SORT_FIELD");return{page:r,limit:s,offset:(r-1)*s,search:i,sortBy:o,desc:"desc"===t.query.sortOrder}}sorted(t,e,r){if(!e)return t;const s=r?-1:1;return t.sort((t,r)=>text(t[e]).localeCompare(text(r[e]))*s)}groupOf(t,e){const r=read(e,this.cn)||"",s=dnKey(this.userBaseOf(t)),i=values(e.member).filter(t=>!n(t,this.config.group_dummy_user)).map(t=>dnKey(parentOf(t))===s?rdnValue(t):t);return{id:r,cn:r,displayName:read(e,this.displayName)??r,description:read(e,"description"),color:read(e,this.color),organizationId:t,baseDN:this.groupDn(t,r),members:i,createdAt:read(e,this.createdAt)}}async groups(t,e,r,s){try{const{searchEntries:i}=await this.ldap.search({paged:!1,scope:"one",filter:e,...r&&{attributes:r}},this.groupBaseOf(t),s);return i}catch(t){if(32===i(t))return[];throw t}}async readGroup(t,e,r){try{const{searchEntries:s}=await this.ldap.search({paged:!1,scope:"base"},this.groupDn(t,e),r);return s[0]&&this.groupOf(t,s[0])}catch(t){if(32===i(t)||u(t))return;throw t}}async named(t,e){const[r]=await this.groups(t,`(${this.displayName}=${c(e)})`);return r&&read(r,this.cn)}async onGroup(t){try{return await t()}catch(t){if(32===i(t))throw notFound("group");throw t}}async listRoute(t,e,r){const s=this.page(t,O);let i=`(${this.cn}=*)`;if(s.search){const t=c(s.search);i=`(&${i}(|(${this.displayName}=*${t}*)(description=*${t}*)(&(!(${this.displayName}=*))(${this.cn}=*${t}*))))`}const o=this.sorted((await this.groups(r,i,void 0,t)).map(t=>this.groupOf(r,t)),s.sortBy,s.desc);e.json({organizationId:r,groups:o.slice(s.offset,s.offset+s.limit),pagination:{page:s.page,limit:s.limit,total:o.length,totalPages:Math.ceil(o.length/s.limit)}})}async createRoute(e,r,s){const i=bodyOf(e);if(!isDisplayName(i.name))throw invalid(N);if(void 0!==i.description&&"string"!=typeof i.description)throw invalid("description must be a string");if(void 0!==i.color&&!isColor(i.color))throw invalid("color must be a hex code (e.g. #RRGGBB or #RGB)");const o=t(),a={objectClass:this.config.group_class||["top","groupOfNames"],[this.displayName]:i.name,[this.createdAt]:(new Date).toISOString()};i.description&&(a.description=i.description),i.color&&(a[this.color]=i.color);const n=this.linked(this.groupDn(s,o),s,a);await this.ldap.authorize("ldapaddrequest",[this.groupDn(s,o),this.candidateGroup(o,[],n),e]);const c=i.name;if(await y(()=>this.named(s,c)))throw new RouteError(409,"Group already exists","GROUP_EXISTS");await this.addGroup(this.groupDn(s,o),[],n,e);const u=await y(()=>this.readGroup(s,o));r.status(201).json(u)}async updateRoute(t,e,r){const s=bodyOf(t),i=t.params.groupId,o=Object.keys(s).filter(t=>!["name","description","color"].includes(t));if(o.length)throw invalid(`Only "name", "description" and "color" can be updated; unsupported field(s): ${o.join(", ")}`);const{name:a,description:n,color:c}=s;if(void 0===a&&void 0===n&&void 0===c)throw invalid('at least one of "name", "description" or "color" must be provided');if(void 0!==a&&!isDisplayName(a))throw invalid(N);if(void 0!==n&&"string"!=typeof n)throw invalid("description must be a string");if(void 0!==c&&""!==c&&!isColor(c))throw invalid("color must be a hex code (e.g. #RRGGBB or #RGB) or empty to clear it");const u={};if(void 0!==a&&(u[this.displayName]=a),void 0!==n&&(u.description=n||[]),void 0!==c&&(u[this.color]=c||[]),await this.ldap.authorize("ldapmodifyrequest",[this.groupDn(r,i),{replace:u},this.opNumber(),t]),void 0!==a){const t=await y(()=>this.named(r,a));if(t&&t!==i)throw new RouteError(409,"Group already exists","GROUP_EXISTS")}await this.onGroup(()=>this.modifyGroup(this.groupDn(r,i),{replace:u},t)),e.json({success:!0})}async membersRoute(t,e,r){const s=this.page(t,$),i=t.params.groupId,o=await this.readGroup(r,i,t);if(!o)throw notFound("group");e.json({organizationId:r,id:i,...await this.memberPage(s,r,o.members,void 0,t)})}async memberPage(t,e,r,s=()=>({}),i){const o=[...new Map(r.map(t=>[t.toLowerCase(),t])).values()],a=await this.profiles(e,o,i);let n=o.map(t=>({...a.get(t.toLowerCase())??{uid:t},...s(t)}));if(t.search){const e=t.search.toLowerCase();n=n.filter(t=>[t.uid,t.displayName||t.cn,t.mail].some(t=>text(t).toLowerCase().includes(e)))}n=this.sorted(n,t.sortBy??"uid",t.desc);const c=Math.ceil(n.length/t.limit);return{members:n.slice(t.offset,t.offset+t.limit),pagination:{page:t.page,limit:t.limit,total:n.length,totalPages:c,hasNextPage:t.page<c,hasPreviousPage:t.page>1}}}async users(t,e,r,s){const{deleted:o,deletedValue:a}=this.attrs,n=o?`(!(${o}=${c(a)}))`:"",d=[];for(let o=0;o<e.length;o+=this.maxPage){const a=`(&(|${e.slice(o,o+this.maxPage).map(t=>`(${this.userAttribute}=${c(t)})`).join("")})${n})`;try{const{searchEntries:e}=await this.ldap.search({paged:!1,scope:"one",filter:a,attributes:r},this.userBaseOf(t),s);d.push(...e)}catch(t){if(32!==i(t)&&!u(t))throw t}}return d}async profiles(t,e,r){const s=[...new Set([...Object.values(this.memberFields),...Object.values(I),j])],i=new Map;for(const o of await this.users(t,e,s,r)){const t=read(o,this.userAttribute);if(!t)continue;const e={};for(const[t,r]of Object.entries(this.memberFields)){const s=read(o,r);void 0!==s&&(e[t]=s)}const r={};for(const[t,e]of Object.entries(I)){const s=read(o,e);void 0!==s&&(r[t]=s)}Object.keys(r).length&&(e.name=r),e.isTechnical="TRUE"===read(o,j)?.toUpperCase(),i.set(t.toLowerCase(),e)}return i}async addMembersRoute(t,e,r){const{usernames:s}=bodyOf(t);if(!Array.isArray(s)||0===s.length)throw invalid("usernames must be a non-empty array");if(!s.every(t=>"string"==typeof t&&t.length>0))throw invalid("usernames must be an array of non-empty strings");if(s.length>this.maxPage)throw invalid(`usernames cannot exceed ${this.maxPage} entries per request`);const i=t.params.groupId,o=[...new Map(s.map(t=>[t.toLowerCase(),t])).keys()];await this.ldap.authorize("ldapmodifyrequest",[this.groupDn(r,i),{add:{member:o.map(t=>this.userDn(r,t))}},this.opNumber(),t]);const a=await this.readGroup(r,i,t);if(!a)throw notFound("group");const n=new Map((await this.users(r,o,[this.userAttribute],t)).map(t=>{const e=read(t,this.userAttribute)||"";return[e.toLowerCase(),e]}));if(o.some(t=>!n.has(t)))throw notFound("user");const c=new Set(a.members.map(t=>t.toLowerCase())),u=o.filter(t=>!c.has(t)).map(t=>this.userDn(r,n.get(t))),d=this.groupDn(r,i);u.length&&(await this.onGroup(()=>this.ldap.modify(d,{add:{member:u}},t)),await this.dropPlaceholder(d,t)),e.json({success:!0})}async dropPlaceholder(t,e){const r=this.config.group_dummy_user;if(r)try{await this.ldap.modify(t,{delete:{member:r}},e)}catch(t){if(![16,65].includes(i(t)??0))throw t}}async removeMemberRoute(t,e,r){const s=this.groupDn(r,t.params.groupId),o=this.userDn(r,t.params.userId);try{await this.ldap.modify(s,{delete:{member:o}},t)}catch(e){const r=i(e);if(32===r)throw notFound("group");if(16===r)throw notFound("member");if(65!==r||!this.config.group_dummy_user)throw e;await this.ldap.modify(s,{replace:{member:[this.config.group_dummy_user]}},t)}e.json({success:!0})}}export{N as NAME_RULE,v as ORG,RouteError,bodyOf,branchPattern,TwakeGroups as default,dnKey,invalid,isDisplayName,movedInto,notFound,organizationIn,parentOf,rdnValue,read,values};
|
|
1
|
+
import{randomUUID as t}from"node:crypto";import{BadRequestError as e,AUTHZ_REFUSED as r,HttpError as s}from"../../lib/errors.js";import{extractLdapCode as i}from"../../lib/ldapCodes.js";import{parseDn as o,isDummyMemberDn as a,escapeLdapFilter as n,isAuthzRefusal as c,escapeDnValue as u,rdnValue as h,fillPattern as d,normalizeDn as p,unescapeDnValue as m}from"../../lib/utils.js";import g from"../ldap/groups.js";import{BaseResolver as l}from"../scim/baseResolver.js";import{mergeMapping as f,loadMappingFile as w,DEFAULT_GROUP_MAPPING as y}from"../scim/mapping.js";import{outsideRequest as b}from"../../lib/requestContext.js";import{valueOf as _,lifecycleAttributes as v,first as O}from"./lifecycleAttributes.js";const read=(t,e)=>O(_(t,e)),$="{org}";function withOrg(t,e){return d(t,$,u(e))}const k=["displayName","description","createdAt"],I=["uid","displayName","mail","jobTitle"],j={uid:"uid",_id:"entryUUID",cn:"cn",sn:"sn",givenName:"givenName",displayName:"displayName",mail:"mail",mobile:"mobile",jobTitle:"twakeJobTitle",company:"twakeCompany",organizationRole:"twakeOrganizationRole",organizationId:"twakeOrganizationId"},N={familyName:"sn",givenName:"givenName",additionalName:"twakeAdditionalName",namePrefix:"twakeNamePrefix"},R="twakeIsTechnical";class RouteError extends Error{constructor(t,e,r){super(e),this.status=t,this.code=r}}const notFound=t=>new RouteError(404,`${t[0].toUpperCase()}${t.slice(1)} not found`,`${t.toUpperCase()}_NOT_FOUND`),invalid=(t,e="INVALID_INPUT")=>new RouteError(400,t,e),P="name must be a non-blank string of at most 256 characters with no control characters";function isDisplayName(t){return"string"==typeof t&&t.trim().length>0&&t.length<=256&&!/[\x00-\x1f\x7f]/.test(t)}const z=/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/,isColor=t=>"string"==typeof t&&z.test(t),text=t=>"string"==typeof t||"number"==typeof t?String(t):"";function bodyOf(t){const e=t.body;if(!e||"object"!=typeof e||Array.isArray(e))throw invalid("Request body must be a JSON object");return e}function values(t){return null==t?[]:(Array.isArray(t)?t:[t]).map(t=>t.toString())}function dnKey(t){try{return p(t)}catch{return t.toLowerCase()}}function parentOf(t){return o(t).slice(1).join(",")}function branchPattern(t){const[e,r]=o(t).join(",").split($).map(t=>t.replace(/[.*+?^${}()|[\]\\]/g,"\\$&"));return new RegExp(`^${e}(?<org>(?:\\\\.|[^,])+)${r}$`,"i")}function movedInto(t,e,r){const s=organizationIn(t,r);return void 0!==s&&dnKey(parentOf(r))!==dnKey(parentOf(e))?s:void 0}const A=["twakegroup","twakestaticgroup"],C=Symbol("whole member lists");function organizationIn(t,e){const r=t.exec(parentOf(o(e).join(",")));return r?.groups?m(r.groups.org):void 0}class TwakeGroups extends g{constructor(r){super(r),this.name="twakeGroups",this.hooks={ldapaddrequest:([t,e,r])=>{this.checkMembers(t,e.member);const s=this.organizationOf(t);return void 0!==s&&void 0!==_(e,this.displayName)&&void 0===_(e,this.createdAt)&&(e={...e,[this.createdAt]:(new Date).toISOString()}),void 0!==s&&(e=this.linked(t,s,e)),[t,e,r]},ldapmodifyrequest:([t,e,r,s])=>(this.checkMembers(t,e.add?.member),this.checkMembers(t,e.replace?.member),this.checkLinkChange(t,e),[t,e,r,s]),ldaprenamerequest:async([t,r,s])=>{const o=movedInto(this.groupPattern,t,r);if(void 0===o)return[t,r,s];let a;try{const{searchEntries:e}=await this.ldap.system.search({paged:!1,scope:"base",attributes:["member"],[C]:!0},t);a=e[0]?.member}catch(t){if(32!==i(t))throw t}if(void 0!==this.foreign(o,a))throw new e(`The group holds members of another organization than ${o}`);return[t,r,s]},ldaprenamedone:async([t,e])=>{const r=movedInto(this.groupPattern,t,e),s=this.config.ldap_organization_link_attribute;if(void 0===r||!s||!this.orgPattern||!this.judgedByAttachment())return;const{searchEntries:i}=await this.ldap.system.search({paged:!1,scope:"base",attributes:[s]},e);void 0!==_(i[0]??{},s)&&await this.ldap.system.modify(e,{replace:{[s]:withOrg(this.orgPattern,r)}})},ldapsearchfilter:async([t,e,r])=>[r?.[C]?t:await this.hideTombstones(t),e,r],scimgroupcreate:([e,r,s])=>(s??(s=new l(this.config).groupBase(r)),[this.groupPattern.test(o(s).join(","))?{...e,id:t()}:e,r,s])},this.groupBase=this.config.twake_group_base||"",this.userBase=this.config.twake_group_user_base||"";for(const[t,e]of[["--twake-group-base",this.groupBase],["--twake-group-user-base",this.userBase]])if(2!==e.split($).length)throw new Error(`${this.name}: ${t} must hold ${$} once`);this.groupPattern=branchPattern(this.groupBase),this.attrs=v(this.config),this.orgPattern=this.config.twake_group_organization_dn||"",this.displayName=this.config.twake_group_display_name_attribute||"twakeDisplayName",this.color=this.config.twake_group_color_attribute||"twakeGroupColor",this.createdAt=this.config.twake_group_created_at_attribute||"twakeCreatedAt",this.maxPage=this.config.twake_group_max_page_limit||1e3,this.memberFields={...j,...this.config.twake_group_member_fields}}adaptSchema(t){const e={[this.displayName]:{type:"string",required:!0},[this.color]:{type:"string",test:z.source},[this.createdAt]:{type:"string"}},r=this.config.ldap_organization_link_attribute;r&&this.orgPattern&&(e[r]={type:"string",generated:!0});for(const[r,s]of Object.entries(e)){const e=Object.keys(t.attributes).find(t=>t.toLowerCase()===r.toLowerCase()),i=e?t.attributes[e]:s;e&&delete t.attributes[e],t.attributes[r]=i}return t}afterLoad(){if(!this.server.loadedPlugins.scim)return;const t=this.config.scim_group_rdn_attribute||"cn",e=this.config.scim_group_mapping||"",r=f(y,e?w(e):void 0).entries.find(e=>e.ldap?.toLowerCase()===t.toLowerCase());r&&this.logger.warn(`${this.name}: the SCIM group mapping writes ${r.scim} to ${t}, so SCIM groups keep it as their RDN instead of a generated cn: map ${r.scim} to another attribute with --scim-group-mapping`)}linked(t,r,s){const i=this.config.ldap_organization_link_attribute;if(!i||!this.orgPattern||!this.judgedByAttachment())return s;const o=withOrg(this.orgPattern,r),a=read(s,i);if(void 0!==a){if(dnKey(a)!==dnKey(o))throw new e(`${t} lies in the branch of ${r}, and must be linked to it`);return s}const n=values(s.objectClass);return n.some(t=>A.includes(t.toLowerCase()))||n.push("twakeGroup"),{...s,objectClass:n,[i]:o}}checkLinkChange(t,r){const s=this.config.ldap_organization_link_attribute,i=this.organizationOf(t);if(void 0===i||!s||!this.orgPattern||!this.judgedByAttachment())return;const o=dnKey(withOrg(this.orgPattern,i)),named=t=>Object.keys(t??{}).filter(t=>t.toLowerCase()===s.toLowerCase()),a=[...named(r.add).flatMap(t=>values(r.add[t])),...named(r.replace).flatMap(t=>values(r.replace[t]))],n=Array.isArray(r.delete)?r.delete.some(t=>t.toLowerCase()===s.toLowerCase()):named(r.delete).length>0,c=named(r.replace).length>0&&0===a.length;if(n||c||a.some(t=>dnKey(t)!==o))throw new e(`${t} lies in the branch of ${i}, and must stay linked to it`)}judgedByAttachment(){return Object.values(this.server.loadedPlugins).some(t=>t.roles?.includes("authz")&&!0===t.judgesByAttachment?.())}organizationOf(t){return organizationIn(this.groupPattern,t)}groupBaseOf(t){return withOrg(this.groupBase,t)}userBaseOf(t){return withOrg(this.userBase,t)}checkMembers(t,r){const s=this.organizationOf(t);if(!s)return;const i=this.foreign(s,r);if(void 0!==i)throw new e(`${i} is not a user of organization ${s}`)}foreign(t,e){const r=dnKey(this.userBaseOf(t));return values(e).find(t=>!a(t,this.config.group_dummy_user)&&dnKey(parentOf(t))!==r)}async hideTombstones(t){if(!this.attrs.deleted)return t;const e=new Set;for(const r of t.searchEntries){const t=this.organizationOf(r.dn);t&&void 0!==r.member&&e.add(t)}if(0===e.size)return t;const r=new Set;for(const t of e)for(const e of await this.tombstonesOf(t))r.add(dnKey(e));return 0===r.size?t:{...t,searchEntries:t.searchEntries.map(t=>void 0!==t.member&&this.organizationOf(t.dn)?{...t,member:values(t.member).filter(t=>!r.has(dnKey(t)))}:t)}}async tombstonesOf(t){const{deleted:e,deletedValue:r}=this.attrs;if(!e)return[];try{const{searchEntries:s}=await this.ldap.system.search({paged:!1,scope:"one",filter:`(${e}=${n(r)})`,attributes:["dn"]},this.userBaseOf(t));return s.map(t=>t.dn)}catch(t){if(32===i(t))return[];throw t}}api(t){const e=`${this.config.api_prefix}/v1/organizations/:id/groups`,r=this.organizationRoute.bind(this);t.get(e,r("listing groups",(t,e,r)=>this.listRoute(t,e,r))),t.post(e,r("creating group",(t,e,r)=>this.createRoute(t,e,r))),t.get(`${e}/:groupId`,r("getting group",async(t,e,r)=>{const s=await this.readGroup(r,t.params.groupId,t);if(!s)throw notFound("group");e.json(s)})),t.get(`${e}/:groupId/members`,r("listing group members",(t,e,r)=>this.membersRoute(t,e,r))),t.patch(`${e}/:groupId`,r("updating group",(t,e,r)=>this.updateRoute(t,e,r))),t.delete(`${e}/:groupId`,r("deleting group",async(t,e,r)=>{await this.onGroup(()=>this.deleteGroup(this.groupDn(r,t.params.groupId),t)),e.json({success:!0})})),t.post(`${e}/:groupId/members`,r("adding group members",(t,e,r)=>this.addMembersRoute(t,e,r))),t.delete(`${e}/:groupId/members/:userId`,r("removing group member",(t,e,r)=>this.removeMemberRoute(t,e,r)))}organizationRoute(t,e,i=this.name){return async(o,a)=>{try{const t=o.params.id;await this.checkOrganization(t,o),await e(o,a,t)}catch(e){if(e instanceof RouteError)return void a.status(e.status).json({error:e.message,code:e.code});if(c(e))return void a.status(403).json({error:r,code:"REFUSED"});if(e instanceof s&&e.statusCode<500)return void a.status(e.statusCode).json({error:e.message,code:400===e.statusCode?"INVALID_INPUT":"REFUSED"});this.logger.error({plugin:i,context:t,error:String(e)}),a.status(500).json({error:`Error ${t}`,code:"INTERNAL_ERROR"})}}}groupDn(t,e){return`${this.cn}=${u(e)},${this.groupBaseOf(t)}`}get userAttribute(){return this.config.ldap_user_main_attribute||"uid"}userDn(t,e){return`${this.userAttribute}=${u(e)},${this.userBaseOf(t)}`}async checkOrganization(t,e){if(!this.orgPattern)return;const r=this.config.twake_group_organization_status_attribute||"twakeOrgStatus",s=withOrg(this.orgPattern,t);if(!await this.ldap.readableBy(s,e))throw notFound("organization");let o;try{o=(await this.ldap.system.search({paged:!1,scope:"base",attributes:[r]},s)).searchEntries[0]}catch(t){if(32!==i(t))throw t}if(!o)throw notFound("organization");const a=this.config.twake_group_organization_deleted_value||"deleted";if(read(o,r)===a)throw new RouteError(410,"Organization has been deleted","ORGANIZATION_DELETED")}page(t,e){const r=Math.max(1,parseInt(t.query.page)||1),s=Math.max(1,Math.min(parseInt(t.query.limit)||20,this.maxPage)),i=t.query.search||void 0;if(void 0!==i&&("string"!=typeof i||i.length<2))throw invalid("Search query must be at least 2 characters","INVALID_SEARCH_QUERY");const o=t.query.sortBy;if(void 0!==o&&!e.includes(o))throw invalid(`sortBy must be one of: ${e.join(", ")}`,"INVALID_SORT_FIELD");return{page:r,limit:s,offset:(r-1)*s,search:i,sortBy:o,desc:"desc"===t.query.sortOrder}}sorted(t,e,r){if(!e)return t;const s=r?-1:1;return t.sort((t,r)=>text(t[e]).localeCompare(text(r[e]))*s)}groupOf(t,e){const r=read(e,this.cn)||"",s=dnKey(this.userBaseOf(t)),i=values(e.member).filter(t=>!a(t,this.config.group_dummy_user)).map(t=>dnKey(parentOf(t))===s?h(t):t);return{id:r,cn:r,displayName:read(e,this.displayName)??r,description:read(e,"description"),color:read(e,this.color),organizationId:t,baseDN:this.groupDn(t,r),members:i,createdAt:read(e,this.createdAt)}}async groups(t,e,r,s){try{const{searchEntries:i}=await this.ldap.search({paged:!1,scope:"one",filter:e,...r&&{attributes:r}},this.groupBaseOf(t),s);return i}catch(t){if(32===i(t))return[];throw t}}async readGroup(t,e,r){try{const{searchEntries:s}=await this.ldap.search({paged:!1,scope:"base"},this.groupDn(t,e),r);return s[0]&&this.groupOf(t,s[0])}catch(t){if(32===i(t)||c(t))return;throw t}}async named(t,e){const[r]=await this.groups(t,`(${this.displayName}=${n(e)})`);return r&&read(r,this.cn)}async onGroup(t){try{return await t()}catch(t){if(32===i(t))throw notFound("group");throw t}}async listRoute(t,e,r){const s=this.page(t,k);let i=`(${this.cn}=*)`;if(s.search){const t=n(s.search);i=`(&${i}(|(${this.displayName}=*${t}*)(description=*${t}*)(&(!(${this.displayName}=*))(${this.cn}=*${t}*))))`}const o=this.sorted((await this.groups(r,i,void 0,t)).map(t=>this.groupOf(r,t)),s.sortBy,s.desc);e.json({organizationId:r,groups:o.slice(s.offset,s.offset+s.limit),pagination:{page:s.page,limit:s.limit,total:o.length,totalPages:Math.ceil(o.length/s.limit)}})}async createRoute(e,r,s){const i=bodyOf(e);if(!isDisplayName(i.name))throw invalid(P);if(void 0!==i.description&&"string"!=typeof i.description)throw invalid("description must be a string");if(void 0!==i.color&&!isColor(i.color))throw invalid("color must be a hex code (e.g. #RRGGBB or #RGB)");const o=t(),a={objectClass:this.config.group_class||["top","groupOfNames"],[this.displayName]:i.name,[this.createdAt]:(new Date).toISOString()};i.description&&(a.description=i.description),i.color&&(a[this.color]=i.color);const n=this.linked(this.groupDn(s,o),s,a);await this.ldap.authorize("ldapaddrequest",[this.groupDn(s,o),this.candidateGroup(o,[],n),e]);const c=i.name;if(await b(()=>this.named(s,c)))throw new RouteError(409,"Group already exists","GROUP_EXISTS");await this.addGroup(this.groupDn(s,o),[],n,e);const u=await b(()=>this.readGroup(s,o));r.status(201).json(u)}async updateRoute(t,e,r){const s=bodyOf(t),i=t.params.groupId,o=Object.keys(s).filter(t=>!["name","description","color"].includes(t));if(o.length)throw invalid(`Only "name", "description" and "color" can be updated; unsupported field(s): ${o.join(", ")}`);const{name:a,description:n,color:c}=s;if(void 0===a&&void 0===n&&void 0===c)throw invalid('at least one of "name", "description" or "color" must be provided');if(void 0!==a&&!isDisplayName(a))throw invalid(P);if(void 0!==n&&"string"!=typeof n)throw invalid("description must be a string");if(void 0!==c&&""!==c&&!isColor(c))throw invalid("color must be a hex code (e.g. #RRGGBB or #RGB) or empty to clear it");const u={};if(void 0!==a&&(u[this.displayName]=a),void 0!==n&&(u.description=n||[]),void 0!==c&&(u[this.color]=c||[]),await this.ldap.authorize("ldapmodifyrequest",[this.groupDn(r,i),{replace:u},this.opNumber(),t]),void 0!==a){const t=await b(()=>this.named(r,a));if(t&&t!==i)throw new RouteError(409,"Group already exists","GROUP_EXISTS")}await this.onGroup(()=>this.modifyGroup(this.groupDn(r,i),{replace:u},t)),e.json({success:!0})}async membersRoute(t,e,r){const s=this.page(t,I),i=t.params.groupId,o=await this.readGroup(r,i,t);if(!o)throw notFound("group");e.json({organizationId:r,id:i,...await this.memberPage(s,r,o.members,void 0,t)})}async memberPage(t,e,r,s=()=>({}),i){const o=[...new Map(r.map(t=>[t.toLowerCase(),t])).values()],a=await this.profiles(e,o,i);let n=o.map(t=>({...a.get(t.toLowerCase())??{uid:t},...s(t)}));if(t.search){const e=t.search.toLowerCase();n=n.filter(t=>[t.uid,t.displayName||t.cn,t.mail].some(t=>text(t).toLowerCase().includes(e)))}n=this.sorted(n,t.sortBy??"uid",t.desc);const c=Math.ceil(n.length/t.limit);return{members:n.slice(t.offset,t.offset+t.limit),pagination:{page:t.page,limit:t.limit,total:n.length,totalPages:c,hasNextPage:t.page<c,hasPreviousPage:t.page>1}}}async users(t,e,r,s){const{deleted:o,deletedValue:a}=this.attrs,u=o?`(!(${o}=${n(a)}))`:"",h=[];for(let o=0;o<e.length;o+=this.maxPage){const a=`(&(|${e.slice(o,o+this.maxPage).map(t=>`(${this.userAttribute}=${n(t)})`).join("")})${u})`;try{const{searchEntries:e}=await this.ldap.search({paged:!1,scope:"one",filter:a,attributes:r},this.userBaseOf(t),s);h.push(...e)}catch(t){if(32!==i(t)&&!c(t))throw t}}return h}async profiles(t,e,r){const s=[...new Set([...Object.values(this.memberFields),...Object.values(N),R])],i=new Map;for(const o of await this.users(t,e,s,r)){const t=read(o,this.userAttribute);if(!t)continue;const e={};for(const[t,r]of Object.entries(this.memberFields)){const s=read(o,r);void 0!==s&&(e[t]=s)}const r={};for(const[t,e]of Object.entries(N)){const s=read(o,e);void 0!==s&&(r[t]=s)}Object.keys(r).length&&(e.name=r),e.isTechnical="TRUE"===read(o,R)?.toUpperCase(),i.set(t.toLowerCase(),e)}return i}async addMembersRoute(t,e,r){const{usernames:s}=bodyOf(t);if(!Array.isArray(s)||0===s.length)throw invalid("usernames must be a non-empty array");if(!s.every(t=>"string"==typeof t&&t.length>0))throw invalid("usernames must be an array of non-empty strings");if(s.length>this.maxPage)throw invalid(`usernames cannot exceed ${this.maxPage} entries per request`);const i=t.params.groupId,o=[...new Map(s.map(t=>[t.toLowerCase(),t])).keys()];await this.ldap.authorize("ldapmodifyrequest",[this.groupDn(r,i),{add:{member:o.map(t=>this.userDn(r,t))}},this.opNumber(),t]);const a=await this.readGroup(r,i,t);if(!a)throw notFound("group");const n=new Map((await this.users(r,o,[this.userAttribute],t)).map(t=>{const e=read(t,this.userAttribute)||"";return[e.toLowerCase(),e]}));if(o.some(t=>!n.has(t)))throw notFound("user");const c=new Set(a.members.map(t=>t.toLowerCase())),u=o.filter(t=>!c.has(t)).map(t=>this.userDn(r,n.get(t))),h=this.groupDn(r,i);u.length&&(await this.onGroup(()=>this.ldap.modify(h,{add:{member:u}},t)),await this.dropPlaceholder(h,t)),e.json({success:!0})}async dropPlaceholder(t,e){const r=this.config.group_dummy_user;if(r)try{await this.ldap.modify(t,{delete:{member:r}},e)}catch(t){if(![16,65].includes(i(t)??0))throw t}}async removeMemberRoute(t,e,r){const s=this.groupDn(r,t.params.groupId),o=this.userDn(r,t.params.userId);try{await this.ldap.modify(s,{delete:{member:o}},t)}catch(e){const r=i(e);if(32===r)throw notFound("group");if(16===r)throw notFound("member");if(65!==r||!this.config.group_dummy_user)throw e;await this.ldap.modify(s,{replace:{member:[this.config.group_dummy_user]}},t)}e.json({success:!0})}}export{P as NAME_RULE,$ as ORG,RouteError,bodyOf,branchPattern,TwakeGroups as default,dnKey,invalid,isDisplayName,movedInto,notFound,organizationIn,parentOf,read,values,withOrg};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import t from"node-fetch";import{TwakePlugin as e}from"../../abstract/twakePlugin.js";import{roleAttribute as a}from"../../config/schema.js";import{asyncHandler as i,escapeLdapFilter as s,isDnInBranch as r,parseDn as n,normalizeDn as o,isDummyMemberDn as l}from"../../lib/utils.js";import{ConflictError as d}from"../../lib/errors.js";import{extractLdapCode as m}from"../../lib/ldapCodes.js";import{currentOperation as h}from"../../lib/operation.js";import{wantJson as u}from"../../lib/expressFormatedResponses.js";import{domainBranches as c,mailDomains as g}from"../../lib/mailDomains.js";const p=[400,404],f=[500,502,503,504],pickDefaultIdentity=t=>(Array.isArray(t)?t:[t]).filter(t=>t&&t.id&&!1!==t.mayDelete).sort((t,e)=>(t.sortOrder??0)-(e.sortOrder??0))[0],members=t=>valuesOf(t??void 0,"member")??[],sameAttr=(t,e)=>t.split(";")[0].toLowerCase()===e.split(";")[0].toLowerCase(),valuesOf=(t,e)=>{if(!t)return;const a=Object.keys(t).filter(t=>sameAttr(t,e));return a.length?a.flatMap(e=>{return null==(a=t[e])?[]:(Array.isArray(a)?a:[a]).map(String);var a}):void 0},valuesAfter=(t,e,a,i)=>{let s=valuesOf(t,a)??[];const r=valuesOf(e.add,a);r&&(s=[...s,...r]);const n=valuesOf(e.replace,a);n&&(s=n);const o=e.delete;if(Array.isArray(o))o.some(t=>sameAttr(t,a))&&(s=[]);else{const t=valuesOf(o,a);if(t){const e=new Set(t.map(i));s=e.size?s.filter(t=>!e.has(i(t))):[]}}return s},memberKey=t=>{try{return o(t)}catch{return t.toLowerCase()}},b=["groupofnames","groupofuniquenames","groupofurls"],isGroup=t=>(Array.isArray(t)?t:t?[t]:[]).some(t=>b.includes(String(t).toLowerCase())),sameDn=(t,e)=>{try{return o(t)===o(e)}catch{return!1}};class James extends e{constructor(t){super(t,"james_webadmin_url","james_webadmin_token","james_concurrency"),this.name="james",this.roles=["consistency","api","configurable"],this.dependencies={onLdapChange:"core/ldap/onChange",ldapGroups:"core/ldap/groups"},this.hooks={ldapaddrequest:async([t,e,a])=>{const i=this.teamMailboxDomain(valuesOf(e,this.mailAttr),valuesOf(e,this.mailboxTypeAttr));return i&&await this.assertMembersAt(t,i,valuesOf(e,"member")??[]),void 0!==a?[t,e,a]:[t,e]},ldapmodifyrequest:async([t,e,a,i])=>(await this.guardTeamMailboxChange(t,e),await this.guardDomainChange(t,e),[t,e,a,i]),ldapdeleterequest:async([t,e])=>{for(const e of Array.isArray(t)?t:[t])e&&await this.guardDomainRemoval(e,"delete");return[t,e]},ldaprenamerequest:async([t,e,a])=>(await this.guardDomainMove(t,e),[t,e,a]),ldaprenamedone:async([t,e])=>{void 0===h()&&await this.followMove(t,e)},ldapadddone:([t,e])=>this.provisionAccount(t,e),onLdapMailChange:async(t,e,a)=>{const i=e?Array.isArray(e)?String(e[0]):String(e):null,s=a?Array.isArray(a)?String(a[0]):String(a):null;if(!i)return void this.logger.debug(`Skipping mail rename for ${t}: oldmail is empty (mail attribute was added, not changed)`);if(!s)return void this.logger.debug(`Skipping mail rename for ${t}: newmail is empty (mail attribute was deleted, not changed)`);if(await this.isGroupDn(t))return;const r=await this.defaultSignatures(i),n=await this.ldapGetAttributes(t,[this.forwardAttr,this.displayNameAttr,"cn","givenName","sn",this.mailAttr]);await this.dropStaleForwards(t,s,this.ownForwards(n?.[this.forwardAttr])),await this.callWebAdminApi("onLdapMailChange",`${this.webadminUrl}/users/${i}/rename/${s}?action=rename&force`,"POST",t,null,{oldmail:i,newmail:s},p);try{const e=await this.server.ldap.system.search({paged:!1,scope:"base",attributes:[this.aliasAttr]},t);if(e.searchEntries&&e.searchEntries.length>0){const a=this.getAliases(e.searchEntries[0][this.aliasAttr]);a.length>0&&await Promise.all([...a.map(e=>this.callWebAdminApi("onLdapMailChange-delete",`${this.webadminUrl}/address/aliases/${i}/sources/${e}`,"DELETE",t,null,{oldmail:i,alias:e})),...a.map(e=>this.callWebAdminApi("onLdapMailChange-create",`${this.webadminUrl}/address/aliases/${s}/sources/${e}`,"PUT",t,null,{newmail:s,alias:e},p))])}}catch(t){this.logger.debug("Could not fetch aliases for mail change:",t)}const o=n&&this.getDisplayNameFromAttributes(n);o&&await this.updateJamesIdentity(t,s,o,r)},onLdapAliasChange:async(t,e,a,i)=>{if(await this.isGroupDn(t))return;const s=a.map(t=>this.normalizeAlias(t)),r=i.map(t=>this.normalizeAlias(t)),n=s.filter(t=>!r.includes(t)),o=r.filter(t=>!s.includes(t));await Promise.all([...n.map(a=>this.callWebAdminApi("onLdapAliasChange-delete",`${this.webadminUrl}/address/aliases/${e}/sources/${a}`,"DELETE",t,null,{mail:e,alias:a,action:"delete"})),...o.map(async a=>{await this.dropStaleForwards(t,a,[]),await this.callWebAdminApi("onLdapAliasChange-add",`${this.webadminUrl}/address/aliases/${e}/sources/${a}`,"PUT",t,null,{mail:e,alias:a,action:"add"},p)})])},onLdapQuotaChange:async(t,e,a,i)=>{if(await this.isGroupDn(t))return;const s=i>0;return this.callWebAdminApi("onLdapQuotaChange",`${this.webadminUrl}/quota/users/${e}/size`,s?"PUT":"DELETE",t,s?i.toString():null,{oldQuota:a,newQuota:i},p)},onLdapForwardChange:async(t,e,a,i)=>{if(!this.config.james_sync_forwards)return;if(await this.isGroupDn(t))return;const s=this.extractMailDomain(e);if(!s)return void this.logger.error(`Cannot extract domain from mail ${e} for forward management`);const r=a.filter(t=>!i.includes(t)),n=i.filter(t=>!a.includes(t));await Promise.all([...r.map(a=>this.callWebAdminApi("onLdapForwardChange-delete",`${this.webadminUrl}/address/forwards/${e}/targets/${a}`,"DELETE",t,null,{mail:e,forward:a,domain:s,action:"delete"})),...n.map(a=>this.callWebAdminApi("onLdapForwardChange-add",`${this.webadminUrl}/address/forwards/${e}/targets/${a}`,"PUT",t,null,{mail:e,forward:a,domain:s,action:"add"},p))])},onLdapChange:async(t,e)=>{this.config.delegation_attribute&&e[this.config.delegation_attribute]&&!await this.isGroupDn(t)&&await this._handleDelegationChange(t,e)},onLdapDisplayNameChange:async t=>{const e=[this.mailAttr,this.displayNameAttr,"cn","givenName","sn","objectClass"],a=await this.ldapGetAttributes(t,e);if(!a)return void this.logger.warn(`Cannot update James identity: entry not found for ${t}`);if(isGroup(a.objectClass))return;const i=this.attributeToString(a[this.mailAttr]);if(!i)return void this.logger.warn(`Cannot update James identity: no mail found for ${t}`);const s=this.getDisplayNameFromAttributes(a);if(s)return this.updateJamesIdentity(t,i,s);this.logger.warn(`Cannot update James identity: no display name found for ${t}`)},onLdapEntryChange:async(t,e,a)=>{e&&a&&!e[this.mailAttr]&&a[this.mailAttr]&&await this.provisionAccount(t,a),await this.followTeamMailbox(e,a),await this.followDomains(e,a)}},this.domainQueue=new Map,this.teamMailboxQueue=new Map,this.identityQueue=new Map,this.quotaAttr=this.config.quota_attribute,this.aliasAttr=this.config.alias_attribute||"mailAlternateAddress",this.mailboxTypeAttr=this.config.james_mailbox_type_attribute||"twakeMailboxType",this.forwardAttr=this.config.forward_attribute||"mailForwardingAddress",this.initDelay="number"==typeof this.config.james_init_delay?this.config.james_init_delay:1e3,this.retryAttempts="number"==typeof this.config.james_retry_attempts?Math.max(1,this.config.james_retry_attempts):5,this.retryDelay="number"==typeof this.config.james_retry_delay?Math.max(0,this.config.james_retry_delay):1e3}api(t){const e=this.config.api_prefix||"/api";t.get(`${e}/v1/users/:user/quota-usage`,i((t,e)=>this.getUserQuota(t,e))),this.logger.info(`James API registered at ${e}/v1/users/:user/quota-usage`)}async getUserQuota(e,a){if(!u(e,a))return;const i=e.params.user;try{const r=s(i),n=await this.server.ldap.system.search({scope:"sub",filter:`(uid=${r})`,paged:!1,attributes:[this.mailAttr]},this.config.ldap_base||"");let o;for(const t of n.searchEntries??[])if(await this.server.ldap.readableBy(String(t.dn),e)){o=t;break}if(!o)return void a.status(404).json({error:`User ${i} not found`});const l=o[this.mailAttr];if(!l)return void a.status(400).json({error:`User ${i} has no mail attribute`});const d=Array.isArray(l)?String(l[0]):String(l),m=`${this.webadminUrl}/quota/users/${d}`,h=await this.requestLimit(()=>t(m,{method:"GET",headers:this.createHeaders()}));if(!h.ok)return void(404===h.status?a.status(404).json({error:`Quota not found for user ${i}`}):a.status(502).json({error:`Failed to retrieve quota from James: ${h.statusText}`}));const u=await h.json();a.json(u)}catch(t){this.logger.error({plugin:this.name,event:"getUserQuota",username:i,error:t instanceof Error?t.message:String(t)}),a.status(500).json({error:"Internal server error while retrieving quota"})}}domainAttribute(t){let e,a=-1;for(const{base:i,attribute:s}of c(this.server))try{if(sameDn(t,i)||!r(t,i))continue;const o=n(i).length;o>a&&([e,a]=[s,o])}catch{}return e}domainsOf(t){const e=t?.dn&&this.domainAttribute(String(t.dn));return e?g(valuesOf(t,e)):[]}afterLoad(){if(!this.server.loadedPlugins.trash)return;const t=String(this.config.trash_watched_bases??"").split(";").map(t=>t.trim()).filter(Boolean);for(const{base:e}of c(this.server)){const a=t.length?t.find(t=>{try{return r(e,t)||r(t,e)}catch{return!1}}):"every branch";a&&this.logger.warn(`James: the trash watches the mail domains of ${e} (--trash-watched-bases: ${a}): a domain deleted there may skip the check that it is still in use; leave the domain branch out of the trash, see docs/usage/plugins/integrations/james-mail.md#trash`)}}async followDomains(t,e,a=!1){const i=this.domainsOf(t),s=this.domainsOf(e);if(!i.length&&!s.length)return;const r=String((e??t).dn),call=(t,e)=>this.callWebAdminApi("PUT"===e?"domain:create":"domain:delete",`${this.webadminUrl}/domains/${t}`,e,r,null,{domain:t},f);await Promise.all([...s.filter(t=>!i.includes(t)).map(t=>this.inDomainOrder(t,()=>call(t,"PUT"))),...i.filter(t=>!s.includes(t)).map(t=>this.inDomainOrder(t,async()=>{if(await this.domainDeclaredElsewhere(t,r))return;const e=a?await this.addressAt(t):null;e?this.logger.error({plugin:this.name,event:"domain:delete",dn:r,domain:t,result:"skipped",error:`${e} still has an address at ${t}: the domain is kept in James`}):await call(t,"DELETE")}))])}async followMove(t,e){const a=[this.domainAttribute(t),this.domainAttribute(e)].filter(t=>Boolean(t)).filter((t,e,a)=>a.findIndex(e=>sameAttr(e,t))===e);if(!a.length)return;const i=await this.ldapGetAttributes(e,a);i&&await this.followDomains({...i,dn:t},{...i,dn:e},!0)}async inDomainOrder(t,e){const a=(this.domainQueue.get(t)??Promise.resolve()).then(e,e);this.domainQueue.set(t,a);try{await a}finally{this.domainQueue.get(t)===a&&this.domainQueue.delete(t)}}async firstEntry(t,e,a,i){try{const s=await this.server.ldap.system.search({paged:{pageSize:500},scope:"sub",filter:e,attributes:a},t);for await(const t of s)for(const e of t.searchEntries||[])if(i(e))return e;return null}catch(t){if(32===m(t))return null;throw t}}async domainDeclaredElsewhere(t,e){try{for(const{base:a,attribute:i}of c(this.server)){if(await this.firstEntry(a,`(${i}=${s(t)})`,["1.1"],t=>{const a=String(t.dn),s=this.domainAttribute(a);return!sameDn(a,e)&&!!s&&sameAttr(s,i)}))return!0}return!1}catch(a){return this.logger.error({plugin:this.name,event:"domain:delete",dn:e,domain:t,result:"skipped",error:a instanceof Error?a.message:String(a)}),!0}}async guardDomainRemoval(t,e){const a=this.domainAttribute(t);if(!a)return;const i=await this.ldapGetAttributes(t,[a]),s=g(valuesOf(i??void 0,a));if(!s.length)return;const r=await this.domainLinkHolder(t,String(i.dn??t));if(r)throw this.logger.warn(`${e} of ${t} refused: organization ${r} links it`),new d(`Cannot ${e} ${t}: an organization still links mail domain ${s.join(", ")}`);for(const a of s)await this.assertNoAddressAt(t,a,`Cannot ${e} ${t}`)}async guardDomainMove(t,e){const a=this.domainAttribute(t);if(!a)return;const i=this.domainAttribute(e);if(!i)return this.guardDomainRemoval(t,"move");const s=await this.ldapGetAttributes(t,sameAttr(a,i)?[a]:[a,i])??void 0,r=g(valuesOf(s,i));for(const e of g(valuesOf(s,a)))r.includes(e)||await this.assertNoAddressAt(t,e,`Cannot move ${t}`)}async guardDomainChange(t,e){const a=this.domainAttribute(t);if(!a||!((t,e)=>[t.add,t.replace].some(t=>t&&Object.keys(t).some(t=>sameAttr(t,e)))||(Array.isArray(t.delete)?t.delete.some(t=>sameAttr(t,e)):Object.keys(t.delete??{}).some(t=>sameAttr(t,e))))(e,a))return;const i=await this.ldapGetAttributes(t,[a]);if(!i)return;const s=g(valuesAfter(i,e,a,t=>t.toLowerCase()));for(const e of g(valuesOf(i,a)))s.includes(e)||await this.assertNoAddressAt(t,e,`Cannot remove mail domain ${e} from ${t}`)}domainLinkAttributes(){const t=this.server.loadedPlugins.ldapOrganizations,e=this.server.loadedPlugins.ldapFlatGeneric,i=[t?.schema,...(e?.instances||[]).map(t=>t.schema)].map(t=>a(t,"domainLink"));i.push(this.config.enterprise_domain_link_attribute);const s=[];for(const t of i)t&&!s.some(e=>sameAttr(e,t))&&s.push(t);return s}async domainLinkHolder(t,e){const a=this.domainLinkAttributes();if(!a.length)return null;const i=[...new Set([t,e,o(t)])],r=`(|${a.flatMap(t=>i.map(e=>`(${t}=${s(e)})`)).join("")})`,n=await this.firstEntry(this.config.ldap_base||"",r,a,e=>a.some(a=>(valuesOf(e,a)??[]).some(e=>sameDn(e,t))));return n?String(n.dn):null}async addressAt(t){const e=[this.mailAttr,this.aliasAttr],atDomain=e=>this.extractMailDomain(this.normalizeAlias(e.trim()))?.toLowerCase()===t,a=await this.firstEntry(this.config.ldap_base||"",`(|${e.map(t=>`(${t}=*)`).join("")})`,e,t=>e.some(e=>(valuesOf(t,e)??[]).some(atDomain)));return a?String(a.dn):null}async assertNoAddressAt(t,e,a){if(await this.domainDeclaredElsewhere(e,t))return;const i=await this.addressAt(e);if(i)throw this.logger.warn(`${a}: ${i} still has an address at ${e}`),new d(`${a}: addresses at ${e} still exist, and James would refuse their mail`)}teamMailboxOf(t){if(!t||"teamMailbox"!==this.getMailboxType(t))return null;const e=this.attributeToString(t[this.mailAttr]);if(!e)return null;const a=String(t.dn),i=this.config.james_mailing_list_branch||[];if(i.some(t=>a===t||a.endsWith(","+t)))return{mail:e,refused:`Team mailbox cannot be in mailing list branches: ${i.join(", ")}`};const s=e.lastIndexOf("@");return/^[#a-zA-Z0-9_-]{1,255}$/.test(e.slice(0,s))&&this.extractMailDomain(e)?{mail:e,refused:null}:{mail:e,refused:"A team mailbox address is a name of letters, digits, #, - and _ at a domain"}}async provisionAccount(t,e){const a=e[this.mailAttr],i=e[this.quotaAttr],s=e[this.aliasAttr];if(!a||isGroup(e.objectClass))return;const r=Array.isArray(a)?String(a[0]):String(a);if(this.initDelay>0&&await new Promise(t=>setTimeout(t,this.initDelay)),i){const e=Array.isArray(i)?Number(i[0]):Number(i);!isNaN(e)&&e>0&&await this.callWebAdminApi("ldapadddone:quota",`${this.webadminUrl}/quota/users/${r}/size`,"PUT",t,e.toString(),{mail:r,quota:e},p)}const n=this.getAliases(s);await Promise.all([this.dropStaleForwards(t,r,this.ownForwards(e[this.forwardAttr])),...n.map(e=>this.dropStaleForwards(t,e,[]))]),n.length>0&&await Promise.all(n.map(e=>this.callWebAdminApi("ldapadddone:alias",`${this.webadminUrl}/address/aliases/${r}/sources/${e}`,"PUT",t,null,{mail:r,alias:e},p)));const o=this.getDisplayNameFromAttributes(e);o&&await this.updateJamesIdentity(t,r,o)}async followTeamMailbox(t,e){const a=this.teamMailboxOf(t),i=this.teamMailboxOf(e);if(!a&&!i)return;const s=String((e??t).dn);i?.refused&&i.refused!==a?.refused&&this.logger.error({plugin:this.name,event:"teamMailbox",dn:s,mail:i.mail,error:i.refused});const r=a&&!a.refused?a.mail:null,n=i&&!i.refused?i.mail:null;if(!r&&!n)return;const o=memberKey(s),follow=async()=>{if(r&&n&&r.toLowerCase()===n.toLowerCase()){const a=new Map(members(t).map(t=>[memberKey(t),t])),i=[];for(const t of members(e))a.delete(memberKey(t))||i.push(t);return void await Promise.all([this.teamMailboxMembers(s,n,"PUT",i),this.teamMailboxMembers(s,n,"DELETE",[...a.values()])])}r&&await this.teamMailboxMembers(s,r,"DELETE",members(t)),n&&await this.createTeamMailbox(s,e,n)},l=(this.teamMailboxQueue.get(o)??Promise.resolve()).then(follow,follow);this.teamMailboxQueue.set(o,l);try{await l}finally{this.teamMailboxQueue.get(o)===l&&this.teamMailboxQueue.delete(o)}}getConfigApiData(){return{teamMailboxAnyDomain:Boolean(this.config.james_team_mailbox_any_domain)}}teamMailboxDomain(t,e){if(this.config.james_team_mailbox_any_domain||"teamMailbox"!==this.getMailboxType({[this.mailboxTypeAttr]:e}))return null;const a=this.attributeToString(t);return a&&this.extractMailDomain(a)?.toLowerCase()||null}async guardTeamMailboxChange(t,e){if(this.config.james_team_mailbox_any_domain)return;const a=[this.mailAttr,this.mailboxTypeAttr,"member"];if(![e.add,e.replace].some(t=>t&&Object.keys(t).some(t=>a.some(e=>sameAttr(t,e)))))return;const i=await this.ldapGetAttributes(t,a);if(!i)return;const after=(t,a=t=>t.toLowerCase())=>valuesAfter(i,e,t,a),s=this.teamMailboxDomain(after(this.mailAttr),after(this.mailboxTypeAttr));if(!s)return;const r=new Set((valuesOf(i,"member")??[]).map(memberKey)),n=after("member",memberKey),o=n.filter(t=>!r.has(memberKey(t))),l=s!==this.teamMailboxDomain(valuesOf(i,this.mailAttr),valuesOf(i,this.mailboxTypeAttr));await this.assertMembersAt(t,s,o,l?n.filter(t=>r.has(memberKey(t))):[])}async assertMembersAt(t,e,a,i=[]){const foreign=async t=>(await this.getMemberEmails(t)).filter(t=>this.extractMailDomain(t)?.toLowerCase()!==e),[s,r]=await Promise.all([foreign(a),foreign(i)]);if(0===s.length&&0===r.length)return;const n=s.length?`${s.slice(0,5).join(", ")}${s.length>5?", …":""} `+(s.length>1?"are":"is")+" not":"",o=r.length?"some of the members it holds are not":"";throw new d(`The members of team mailbox ${t} must have an address at ${e}: `+[n,o].filter(Boolean).join(", and "))}teamMailboxUrl(t){const e=t.toLowerCase(),a=e.lastIndexOf("@");return`${this.webadminUrl}/domains/${e.slice(a+1)}/team-mailboxes/${encodeURIComponent(e.slice(0,a))}`}async teamMailboxMembers(t,e,a,i){if(!i.length)return;const s=this.teamMailboxUrl(e),r=await this.getMemberEmails(i);await Promise.all(r.map(i=>this.callWebAdminApi("teamMailbox:"+("PUT"===a?"addMember":"removeMember"),`${s}/members/${i}`,a,t,null,{teamMailbox:e,memberMail:i})))}getDisplayNameFromAttributes(t){const e=this.attributeToString(t[this.displayNameAttr]);if(e)return e;const a=this.attributeToString(t.cn);if(a)return a;const i=this.attributeToString(t.givenName),s=this.attributeToString(t.sn);if(i||s){const t=[];return i&&t.push(i),s&&t.push(s),t.join(" ")}const r=this.attributeToString(t[this.mailAttr]);return r||null}async isGroupDn(t){const e=await this.ldapGetAttributes(t,["objectClass"]);return isGroup(e?.objectClass)}async getMailFromDN(t){const e=await this.ldapGetAttributes(t,[this.mailAttr]);return e?this.attributeToString(e[this.mailAttr]):null}async getDisplayNameFromDN(t){const e=[this.displayNameAttr,"cn","givenName","sn",this.mailAttr],a=await this.ldapGetAttributes(t,e);return a?this.getDisplayNameFromAttributes(a):null}async generateSignature(t){const e=this.config.james_signature_template;if(!e)return null;const a=await this.ldapGetAttributes(t);if(!a)return null;let i=e;return i=i.replace(/\{(\w+)\}/g,(t,e)=>"string"==typeof e&&a&&"object"==typeof a&&Object.prototype.hasOwnProperty.call(a,e)&&this.attributeToString(a[e])||""),i}async updateJamesIdentity(t,e,a,i={}){const s=(this.identityQueue.get(e)??Promise.resolve()).then(()=>this.doUpdateJamesIdentity(t,e,a,i));this.identityQueue.set(e,s);try{await s}finally{this.identityQueue.get(e)===s&&this.identityQueue.delete(e)}}async doUpdateJamesIdentity(e,a,i,s){const r={plugin:this.name,event:"onLdapDisplayNameChange",result:"error",dn:e,mail:a,displayName:i};for(let n=1;;n++){try{const o=`${this.webadminUrl}/users/${a}/identities`,l=await this.requestLimit(()=>t(`${o}?default=true`,{method:"GET",headers:this.createHeaders()}));let d;if(l.ok)d=pickDefaultIdentity(await l.json());else if(404!==l.status){const t="get_default_identity";if(this.shouldRetry(l.status,p,n)){await this.warnAndWait(r,t,n,l);continue}return void this.logger.error({...r,step:t,...n>1?{attempts:n}:{},http_status:l.status,http_status_text:l.statusText})}const m=await this.generateSignature(e),h=d?.id?{id:d.id,email:d.email,name:i}:{email:a,name:i,sortOrder:0};m?h.htmlSignature=m:Object.assign(h,s);const u=d?.id?"update_identity":"create_identity",c=await this.requestLimit(()=>t(d?.id?`${o}/${d.id}`:o,{method:d?.id?"PUT":"POST",headers:this.createHeaders("application/json"),body:JSON.stringify(h)}));if(c.ok)this.logger.info({...r,step:u,result:"success",http_status:c.status});else{if(this.shouldRetry(c.status,p,n)){await this.warnAndWait(r,u,n,c);continue}this.logger.error({...r,step:u,...n>1?{attempts:n}:{},http_status:c.status,http_status_text:c.statusText})}}catch(t){this.logger.error({...r,error:`${t}`})}return}}async defaultSignatures(e){try{const a=await this.requestLimit(()=>t(`${this.webadminUrl}/users/${e}/identities?default=true`,{method:"GET",headers:this.createHeaders()}));if(!a.ok)return{};const i=pickDefaultIdentity(await a.json()),s={};return i?.htmlSignature&&(s.htmlSignature=i.htmlSignature),i?.textSignature&&(s.textSignature=i.textSignature),s}catch(t){return this.logger.debug(`Could not read the identities of ${e}: ${t}`),{}}}ownForwards(t){return this.config.james_sync_forwards?this.getAliases(t):[]}async dropStaleForwards(e,a,i){let s;try{const r=await this.requestLimit(()=>t(`${this.webadminUrl}/address/forwards/${a}`,{method:"GET",headers:this.createHeaders()}));if(!r.ok)return void(404!==r.status&&this.logger.warn({plugin:this.name,event:"dropStaleForward",result:"error",dn:e,address:a,step:"list_forwards",http_status:r.status,http_status_text:r.statusText}));const n=i.map(t=>t.toLowerCase());s=(await r.json()).map(t=>t.mailAddress).filter(t=>t&&!n.includes(t.toLowerCase()))}catch(t){return void this.logger.debug(`Could not read the forwards of ${a}: ${t}`)}await Promise.all(s.map(t=>this.callWebAdminApi("dropStaleForward",`${this.webadminUrl}/address/forwards/${a}/targets/${t}`,"DELETE",e,null,{address:a,forward:t})))}async warnAndWait(t,e,a,i){this.logger.warn({...t,step:e,result:"retry",attempt:a,http_status:i.status,http_status_text:i.statusText}),await this.waitBeforeRetry(a)}async deleteUserData(e,a){const i={plugin:this.name,event:"deleteUserData",mail:e,fromStep:a};try{const s=new URL(`${this.webadminUrl}/users/${e}`);s.searchParams.set("action","deleteData"),a&&s.searchParams.set("fromStep",a);const r=await this.requestLimit(()=>t(s.toString(),{method:"POST",headers:this.createHeaders()}));if(!r.ok)return this.logger.error({...i,http_status:r.status,http_status_text:r.statusText}),null;const n=await r.json();return this.logger.info({...i,http_status:r.status,taskId:n.taskId}),n}catch(t){return this.logger.error({...i,error:`${t}`}),null}}getMailboxType(t){const e=t[this.mailboxTypeAttr];if(!e)return null;const a=Array.isArray(e)?String(e[0]):String(e),i=/^\s*cn\s*=\s*([^,]+)/i.exec(a);if(!i)return null;return{group:"group",mailinglist:"mailingList",teammailbox:"teamMailbox"}[i[1].trim().toLowerCase()]??null}async createTeamMailbox(t,e,a){const i=this.teamMailboxUrl(a);await this.callWebAdminApi("teamMailbox:create",i,"PUT",t,null,{teamMailbox:a}),await this.teamMailboxMembers(t,a,"PUT",members(e))}async getMemberEmails(t){const e=t.filter(t=>!l(t,this.config.group_dummy_user)).map(t=>this.server.ldap.queryLimit(async()=>{const e=await this.ldapGetAttributes(t,[this.mailAttr]);return e?this.attributeToString(e[this.mailAttr]):null}));return(await Promise.all(e)).filter(t=>null!==t)}async getGroupMail(t){const e=await this.ldapGetAttributes(t,["mail"]);return e?this.attributeToString(e.mail):null}async getGroupMailboxType(t){try{const e=await this.server.ldap.system.search({paged:!1,scope:"base",attributes:[this.mailboxTypeAttr]},t);if(e.searchEntries&&e.searchEntries.length>0)return this.getMailboxType(e.searchEntries[0])}catch(e){const a=e instanceof Error?e.message:String(e);this.logger.debug(`Could not get mailbox type for group ${t}: ${a}`)}return null}async _handleDelegationChange(t,e){const a=await this.ldapGetAttributes(t,[this.mailAttr]);if(!a)return void this.logger.warn({plugin:this.name,event:"onLdapChange",dn:t,message:"Could not find user entry to get mail attribute"});const i=a[this.mailAttr];if(!i||"string"!=typeof i)return void this.logger.warn({plugin:this.name,event:"onLdapChange",dn:t,message:"User has no mail attribute, cannot manage delegation"});const s=this.config.delegation_attribute;if(!s)return;const[r,n]=e[s]||[],o=this._normalizeToArray(r),l=this._normalizeToArray(n),d=l.filter(t=>!o.includes(t)),m=o.filter(t=>!l.includes(t)),h=d.map(t=>this.server.ldap.queryLimit(async()=>{const e=await this._getDelegateEmail(t);return{dn:t,email:e}})),u=m.map(t=>this.server.ldap.queryLimit(async()=>{const e=await this._getDelegateEmail(t);return{dn:t,email:e}})),[c,g]=await Promise.all([Promise.all(h),Promise.all(u)]),f=[];for(const{dn:e,email:a}of c)a&&f.push(this.callWebAdminApi("onLdapChange:addDelegation",`${this.webadminUrl}/users/${i}/authorizedUsers/${a}`,"PUT",t,null,{userMail:i,delegateEmail:a,delegateDN:e,action:"add"},p));for(const{dn:e,email:a}of g)a&&f.push(this.callWebAdminApi("onLdapChange:removeDelegation",`${this.webadminUrl}/users/${i}/authorizedUsers/${a}`,"DELETE",t,null,{userMail:i,delegateEmail:a,delegateDN:e,action:"remove"}));await Promise.all(f)}async _getDelegateEmail(t){const e=await this.ldapGetAttributes(t,[this.mailAttr]);return e?this.attributeToString(e[this.mailAttr]):(this.logger.warn({plugin:this.name,event:"getDelegateEmail",dn:t,message:"Could not resolve delegate DN to email"}),null)}_normalizeToArray(t){return t?Array.isArray(t)?t:[t]:[]}}export{James as default};
|
|
1
|
+
import{TwakePlugin as t}from"../../abstract/twakePlugin.js";import"node-fetch";import"../../lib/metrics.js";import{roleAttribute as e}from"../../config/schema.js";import{asyncHandler as a,isDnInBranch as i,parseDn as s,escapeLdapFilter as r,normalizeDn as n,isDummyMemberDn as o}from"../../lib/utils.js";import{ConflictError as l}from"../../lib/errors.js";import{extractLdapCode as d}from"../../lib/ldapCodes.js";import{currentOperation as m}from"../../lib/operation.js";import{wantJson as h}from"../../lib/expressFormatedResponses.js";import{domainBranches as u,mailDomains as c}from"../../lib/mailDomains.js";const g=[400,404],p=[500,502,503,504],pickDefaultIdentity=t=>(Array.isArray(t)?t:[t]).filter(t=>t&&t.id&&!1!==t.mayDelete).sort((t,e)=>(t.sortOrder??0)-(e.sortOrder??0))[0],members=t=>valuesOf(t??void 0,"member")??[],sameAttr=(t,e)=>t.split(";")[0].toLowerCase()===e.split(";")[0].toLowerCase(),valuesOf=(t,e)=>{if(!t)return;const a=Object.keys(t).filter(t=>sameAttr(t,e));return a.length?a.flatMap(e=>{return null==(a=t[e])?[]:(Array.isArray(a)?a:[a]).map(String);var a}):void 0},valuesAfter=(t,e,a,i)=>{let s=valuesOf(t,a)??[];const r=valuesOf(e.add,a);r&&(s=[...s,...r]);const n=valuesOf(e.replace,a);n&&(s=n);const o=e.delete;if(Array.isArray(o))o.some(t=>sameAttr(t,a))&&(s=[]);else{const t=valuesOf(o,a);if(t){const e=new Set(t.map(i));s=e.size?s.filter(t=>!e.has(i(t))):[]}}return s},memberKey=t=>{try{return n(t)}catch{return t.toLowerCase()}},f=["groupofnames","groupofuniquenames","groupofurls"],isGroup=t=>(Array.isArray(t)?t:t?[t]:[]).some(t=>f.includes(String(t).toLowerCase())),sameDn=(t,e)=>{try{return n(t)===n(e)}catch{return!1}};class James extends t{health(){return this.webadminHealth()}constructor(t){super(t,"james_webadmin_url","james_webadmin_token","james_concurrency"),this.name="james",this.roles=["consistency","api","configurable"],this.dependencies={onLdapChange:"core/ldap/onChange",ldapGroups:"core/ldap/groups"},this.hooks={ldapaddrequest:async([t,e,a])=>{const i=this.teamMailboxDomain(valuesOf(e,this.mailAttr),valuesOf(e,this.mailboxTypeAttr));return i&&await this.assertMembersAt(t,i,valuesOf(e,"member")??[]),void 0!==a?[t,e,a]:[t,e]},ldapmodifyrequest:async([t,e,a,i])=>(await this.guardTeamMailboxChange(t,e),await this.guardDomainChange(t,e),[t,e,a,i]),ldapdeleterequest:async([t,e])=>{for(const e of Array.isArray(t)?t:[t])e&&await this.guardDomainRemoval(e,"delete");return[t,e]},ldaprenamerequest:async([t,e,a])=>(await this.guardDomainMove(t,e),[t,e,a]),ldaprenamedone:async([t,e])=>{void 0===m()&&await this.followMove(t,e)},ldapadddone:([t,e])=>this.provisionAccount(t,e),onLdapMailChange:async(t,e,a)=>{const i=e?Array.isArray(e)?String(e[0]):String(e):null,s=a?Array.isArray(a)?String(a[0]):String(a):null;if(!i)return void this.logger.debug(`Skipping mail rename for ${t}: oldmail is empty (mail attribute was added, not changed)`);if(!s)return void this.logger.debug(`Skipping mail rename for ${t}: newmail is empty (mail attribute was deleted, not changed)`);if(await this.isGroupDn(t))return;const r=await this.defaultSignatures(i),n=await this.ldapGetAttributes(t,[this.forwardAttr,this.displayNameAttr,"cn","givenName","sn",this.mailAttr]);await this.dropStaleForwards(t,s,this.ownForwards(n?.[this.forwardAttr])),await this.callWebAdminApi("onLdapMailChange",`${this.webadminUrl}/users/${i}/rename/${s}?action=rename&force`,"POST",t,null,{oldmail:i,newmail:s},g);try{const e=await this.server.ldap.system.search({paged:!1,scope:"base",attributes:[this.aliasAttr]},t);if(e.searchEntries&&e.searchEntries.length>0){const a=this.getAliases(e.searchEntries[0][this.aliasAttr]);a.length>0&&await Promise.all([...a.map(e=>this.callWebAdminApi("onLdapMailChange-delete",`${this.webadminUrl}/address/aliases/${i}/sources/${e}`,"DELETE",t,null,{oldmail:i,alias:e})),...a.map(e=>this.callWebAdminApi("onLdapMailChange-create",`${this.webadminUrl}/address/aliases/${s}/sources/${e}`,"PUT",t,null,{newmail:s,alias:e},g))])}}catch(t){this.logger.debug("Could not fetch aliases for mail change:",t)}const o=n&&this.getDisplayNameFromAttributes(n);o&&await this.updateJamesIdentity(t,s,o,r)},onLdapAliasChange:async(t,e,a,i)=>{if(await this.isGroupDn(t))return;const s=a.map(t=>this.normalizeAlias(t)),r=i.map(t=>this.normalizeAlias(t)),n=s.filter(t=>!r.includes(t)),o=r.filter(t=>!s.includes(t));await Promise.all([...n.map(a=>this.callWebAdminApi("onLdapAliasChange-delete",`${this.webadminUrl}/address/aliases/${e}/sources/${a}`,"DELETE",t,null,{mail:e,alias:a,action:"delete"})),...o.map(async a=>{await this.dropStaleForwards(t,a,[]),await this.callWebAdminApi("onLdapAliasChange-add",`${this.webadminUrl}/address/aliases/${e}/sources/${a}`,"PUT",t,null,{mail:e,alias:a,action:"add"},g)})])},onLdapQuotaChange:async(t,e,a,i)=>{if(await this.isGroupDn(t))return;const s=i>0;await this.callWebAdminApi("onLdapQuotaChange",`${this.webadminUrl}/quota/users/${e}/size`,s?"PUT":"DELETE",t,s?i.toString():null,{oldQuota:a,newQuota:i},g)},onLdapForwardChange:async(t,e,a,i)=>{if(!this.config.james_sync_forwards)return;if(await this.isGroupDn(t))return;const s=this.extractMailDomain(e);if(!s)return void this.logger.error(`Cannot extract domain from mail ${e} for forward management`);const r=a.filter(t=>!i.includes(t)),n=i.filter(t=>!a.includes(t));await Promise.all([...r.map(a=>this.callWebAdminApi("onLdapForwardChange-delete",`${this.webadminUrl}/address/forwards/${e}/targets/${a}`,"DELETE",t,null,{mail:e,forward:a,domain:s,action:"delete"})),...n.map(a=>this.callWebAdminApi("onLdapForwardChange-add",`${this.webadminUrl}/address/forwards/${e}/targets/${a}`,"PUT",t,null,{mail:e,forward:a,domain:s,action:"add"},g))])},onLdapChange:async(t,e)=>{this.config.delegation_attribute&&e[this.config.delegation_attribute]&&!await this.isGroupDn(t)&&await this._handleDelegationChange(t,e)},onLdapDisplayNameChange:async t=>{const e=[this.mailAttr,this.displayNameAttr,"cn","givenName","sn","objectClass"],a=await this.ldapGetAttributes(t,e);if(!a)return void this.logger.warn(`Cannot update James identity: entry not found for ${t}`);if(isGroup(a.objectClass))return;const i=this.attributeToString(a[this.mailAttr]);if(!i)return void this.logger.warn(`Cannot update James identity: no mail found for ${t}`);const s=this.getDisplayNameFromAttributes(a);if(s)return this.updateJamesIdentity(t,i,s);this.logger.warn(`Cannot update James identity: no display name found for ${t}`)},onLdapEntryChange:async(t,e,a)=>{e&&a&&!e[this.mailAttr]&&a[this.mailAttr]&&await this.provisionAccount(t,a),await this.followTeamMailbox(e,a),await this.followDomains(e,a)}},this.domainQueue=new Map,this.teamMailboxQueue=new Map,this.identityQueue=new Map,this.quotaAttr=this.config.quota_attribute,this.aliasAttr=this.config.alias_attribute||"mailAlternateAddress",this.mailboxTypeAttr=this.config.james_mailbox_type_attribute||"twakeMailboxType",this.forwardAttr=this.config.forward_attribute||"mailForwardingAddress",this.initDelay="number"==typeof this.config.james_init_delay?this.config.james_init_delay:1e3,this.retryAttempts="number"==typeof this.config.james_retry_attempts?Math.max(1,this.config.james_retry_attempts):5,this.retryDelay="number"==typeof this.config.james_retry_delay?Math.max(0,this.config.james_retry_delay):1e3}api(t){const e=this.config.api_prefix||"/api";t.get(`${e}/v1/users/:user/quota-usage`,a((t,e)=>this.getUserQuota(t,e))),this.logger.info(`James API registered at ${e}/v1/users/:user/quota-usage`)}async getUserQuota(t,e){if(!h(t,e))return;const a=t.params.user;try{const i=await this.findReadableUser(a,t,[this.mailAttr]);if(!i)return void e.status(404).json({error:`User ${a} not found`});const s=i[this.mailAttr];if(!s)return void e.status(400).json({error:`User ${a} has no mail attribute`});const r=Array.isArray(s)?String(s[0]):String(s),n=`${this.webadminUrl}/quota/users/${r}`,o=await this.requestLimit(()=>this.fetch(n,{method:"GET",headers:this.createHeaders()}));if(!o.ok)return void(404===o.status?e.status(404).json({error:`Quota not found for user ${a}`}):e.status(502).json({error:`Failed to retrieve quota from James: ${o.statusText}`}));const l=await o.json();e.json(l)}catch(t){this.logger.error({plugin:this.name,event:"getUserQuota",username:a,error:t instanceof Error?t.message:String(t)}),e.status(500).json({error:"Internal server error while retrieving quota"})}}domainAttribute(t){let e,a=-1;for(const{base:r,attribute:n}of u(this.server))try{if(sameDn(t,r)||!i(t,r))continue;const o=s(r).length;o>a&&([e,a]=[n,o])}catch{}return e}domainsOf(t){const e=t?.dn&&this.domainAttribute(String(t.dn));return e?c(valuesOf(t,e)):[]}afterLoad(){if(!this.server.loadedPlugins.trash)return;const t=String(this.config.trash_watched_bases??"").split(";").map(t=>t.trim()).filter(Boolean);for(const{base:e}of u(this.server)){const a=t.length?t.find(t=>{try{return i(e,t)||i(t,e)}catch{return!1}}):"every branch";a&&this.logger.warn(`James: the trash watches the mail domains of ${e} (--trash-watched-bases: ${a}): a domain deleted there may skip the check that it is still in use; leave the domain branch out of the trash, see docs/usage/plugins/integrations/james-mail.md#trash`)}}async followDomains(t,e,a=!1){const i=this.domainsOf(t),s=this.domainsOf(e);if(!i.length&&!s.length)return;const r=String((e??t).dn),call=(t,e)=>this.callWebAdminApi("PUT"===e?"domain:create":"domain:delete",`${this.webadminUrl}/domains/${t}`,e,r,null,{domain:t},p);await Promise.all([...s.filter(t=>!i.includes(t)).map(t=>this.inDomainOrder(t,()=>call(t,"PUT"))),...i.filter(t=>!s.includes(t)).map(t=>this.inDomainOrder(t,async()=>{if(await this.domainDeclaredElsewhere(t,r))return;const e=a?await this.addressAt(t):null;e?this.logger.error({plugin:this.name,event:"domain:delete",dn:r,domain:t,result:"skipped",error:`${e} still has an address at ${t}: the domain is kept in James`}):await call(t,"DELETE")}))])}async followMove(t,e){const a=[this.domainAttribute(t),this.domainAttribute(e)].filter(t=>Boolean(t)).filter((t,e,a)=>a.findIndex(e=>sameAttr(e,t))===e);if(!a.length)return;const i=await this.ldapGetAttributes(e,a);i&&await this.followDomains({...i,dn:t},{...i,dn:e},!0)}async inDomainOrder(t,e){const run=async()=>{await e()},a=(this.domainQueue.get(t)??Promise.resolve()).then(run,run);this.domainQueue.set(t,a);try{await a}finally{this.domainQueue.get(t)===a&&this.domainQueue.delete(t)}}async firstEntry(t,e,a,i){try{const s=await this.server.ldap.system.search({paged:{pageSize:500},scope:"sub",filter:e,attributes:a},t);for await(const t of s)for(const e of t.searchEntries||[])if(i(e))return e;return null}catch(t){if(32===d(t))return null;throw t}}async domainDeclaredElsewhere(t,e){try{for(const{base:a,attribute:i}of u(this.server)){if(await this.firstEntry(a,`(${i}=${r(t)})`,["1.1"],t=>{const a=String(t.dn),s=this.domainAttribute(a);return!sameDn(a,e)&&!!s&&sameAttr(s,i)}))return!0}return!1}catch(a){return this.logger.error({plugin:this.name,event:"domain:delete",dn:e,domain:t,result:"skipped",error:a instanceof Error?a.message:String(a)}),!0}}async guardDomainRemoval(t,e){const a=this.domainAttribute(t);if(!a)return;const i=await this.ldapGetAttributes(t,[a]),s=c(valuesOf(i??void 0,a));if(!s.length)return;const r=await this.domainLinkHolder(t,String(i.dn??t));if(r)throw this.logger.warn(`${e} of ${t} refused: organization ${r} links it`),new l(`Cannot ${e} ${t}: an organization still links mail domain ${s.join(", ")}`);for(const a of s)await this.assertNoAddressAt(t,a,`Cannot ${e} ${t}`)}async guardDomainMove(t,e){const a=this.domainAttribute(t);if(!a)return;const i=this.domainAttribute(e);if(!i)return this.guardDomainRemoval(t,"move");const s=await this.ldapGetAttributes(t,sameAttr(a,i)?[a]:[a,i])??void 0,r=c(valuesOf(s,i));for(const e of c(valuesOf(s,a)))r.includes(e)||await this.assertNoAddressAt(t,e,`Cannot move ${t}`)}async guardDomainChange(t,e){const a=this.domainAttribute(t);if(!a||!((t,e)=>[t.add,t.replace].some(t=>t&&Object.keys(t).some(t=>sameAttr(t,e)))||(Array.isArray(t.delete)?t.delete.some(t=>sameAttr(t,e)):Object.keys(t.delete??{}).some(t=>sameAttr(t,e))))(e,a))return;const i=await this.ldapGetAttributes(t,[a]);if(!i)return;const s=c(valuesAfter(i,e,a,t=>t.toLowerCase()));for(const e of c(valuesOf(i,a)))s.includes(e)||await this.assertNoAddressAt(t,e,`Cannot remove mail domain ${e} from ${t}`)}domainLinkAttributes(){const t=this.server.loadedPlugins.ldapOrganizations,a=this.server.loadedPlugins.ldapFlatGeneric,i=[t?.schema,...(a?.instances||[]).map(t=>t.schema)].map(t=>e(t,"domainLink"));i.push(this.config.enterprise_domain_link_attribute);const s=[];for(const t of i)t&&!s.some(e=>sameAttr(e,t))&&s.push(t);return s}async domainLinkHolder(t,e){const a=this.domainLinkAttributes();if(!a.length)return null;const i=[...new Set([t,e,n(t)])],s=`(|${a.flatMap(t=>i.map(e=>`(${t}=${r(e)})`)).join("")})`,o=await this.firstEntry(this.config.ldap_base||"",s,a,e=>a.some(a=>(valuesOf(e,a)??[]).some(e=>sameDn(e,t))));return o?String(o.dn):null}async addressAt(t){const e=[this.mailAttr,this.aliasAttr],atDomain=e=>this.extractMailDomain(this.normalizeAlias(e.trim()))?.toLowerCase()===t,a=await this.firstEntry(this.config.ldap_base||"",`(|${e.map(t=>`(${t}=*)`).join("")})`,e,t=>e.some(e=>(valuesOf(t,e)??[]).some(atDomain)));return a?String(a.dn):null}async assertNoAddressAt(t,e,a){if(await this.domainDeclaredElsewhere(e,t))return;const i=await this.addressAt(e);if(i)throw this.logger.warn(`${a}: ${i} still has an address at ${e}`),new l(`${a}: addresses at ${e} still exist, and James would refuse their mail`)}teamMailboxOf(t){if(!t||"teamMailbox"!==this.getMailboxType(t))return null;const e=this.attributeToString(t[this.mailAttr]);if(!e)return null;const a=String(t.dn),i=this.config.james_mailing_list_branch||[];if(i.some(t=>a===t||a.endsWith(","+t)))return{mail:e,refused:`Team mailbox cannot be in mailing list branches: ${i.join(", ")}`};const s=e.lastIndexOf("@");return/^[#a-zA-Z0-9_-]{1,255}$/.test(e.slice(0,s))&&this.extractMailDomain(e)?{mail:e,refused:null}:{mail:e,refused:"A team mailbox address is a name of letters, digits, #, - and _ at a domain"}}async provisionAccount(t,e){const a=e[this.mailAttr],i=e[this.quotaAttr],s=e[this.aliasAttr];if(!a||isGroup(e.objectClass))return;const r=Array.isArray(a)?String(a[0]):String(a);if(this.initDelay>0&&await new Promise(t=>setTimeout(t,this.initDelay)),i){const e=Array.isArray(i)?Number(i[0]):Number(i);!isNaN(e)&&e>0&&await this.callWebAdminApi("ldapadddone:quota",`${this.webadminUrl}/quota/users/${r}/size`,"PUT",t,e.toString(),{mail:r,quota:e},g)}const n=this.getAliases(s);await Promise.all([this.dropStaleForwards(t,r,this.ownForwards(e[this.forwardAttr])),...n.map(e=>this.dropStaleForwards(t,e,[]))]),n.length>0&&await Promise.all(n.map(e=>this.callWebAdminApi("ldapadddone:alias",`${this.webadminUrl}/address/aliases/${r}/sources/${e}`,"PUT",t,null,{mail:r,alias:e},g)));const o=this.getDisplayNameFromAttributes(e);o&&await this.updateJamesIdentity(t,r,o)}async followTeamMailbox(t,e){const a=this.teamMailboxOf(t),i=this.teamMailboxOf(e);if(!a&&!i)return;const s=String((e??t).dn);i?.refused&&i.refused!==a?.refused&&this.logger.error({plugin:this.name,event:"teamMailbox",dn:s,mail:i.mail,error:i.refused});const r=a&&!a.refused?a.mail:null,n=i&&!i.refused?i.mail:null;if(!r&&!n)return;const o=memberKey(s),follow=async()=>{if(r&&n&&r.toLowerCase()===n.toLowerCase()){const a=new Map(members(t).map(t=>[memberKey(t),t])),i=[];for(const t of members(e))a.delete(memberKey(t))||i.push(t);return void await Promise.all([this.teamMailboxMembers(s,n,"PUT",i),this.teamMailboxMembers(s,n,"DELETE",[...a.values()])])}r&&await this.teamMailboxMembers(s,r,"DELETE",members(t)),n&&await this.createTeamMailbox(s,e,n)},l=(this.teamMailboxQueue.get(o)??Promise.resolve()).then(follow,follow);this.teamMailboxQueue.set(o,l);try{await l}finally{this.teamMailboxQueue.get(o)===l&&this.teamMailboxQueue.delete(o)}}getConfigApiData(){return{teamMailboxAnyDomain:Boolean(this.config.james_team_mailbox_any_domain)}}teamMailboxDomain(t,e){if(this.config.james_team_mailbox_any_domain||"teamMailbox"!==this.getMailboxType({[this.mailboxTypeAttr]:e}))return null;const a=this.attributeToString(t);return a&&this.extractMailDomain(a)?.toLowerCase()||null}async guardTeamMailboxChange(t,e){if(this.config.james_team_mailbox_any_domain)return;const a=[this.mailAttr,this.mailboxTypeAttr,"member"];if(![e.add,e.replace].some(t=>t&&Object.keys(t).some(t=>a.some(e=>sameAttr(t,e)))))return;const i=await this.ldapGetAttributes(t,a);if(!i)return;const after=(t,a=t=>t.toLowerCase())=>valuesAfter(i,e,t,a),s=this.teamMailboxDomain(after(this.mailAttr),after(this.mailboxTypeAttr));if(!s)return;const r=new Set((valuesOf(i,"member")??[]).map(memberKey)),n=after("member",memberKey),o=n.filter(t=>!r.has(memberKey(t))),l=s!==this.teamMailboxDomain(valuesOf(i,this.mailAttr),valuesOf(i,this.mailboxTypeAttr));await this.assertMembersAt(t,s,o,l?n.filter(t=>r.has(memberKey(t))):[])}async assertMembersAt(t,e,a,i=[]){const foreign=async t=>(await this.getMemberEmails(t)).filter(t=>this.extractMailDomain(t)?.toLowerCase()!==e),[s,r]=await Promise.all([foreign(a),foreign(i)]);if(0===s.length&&0===r.length)return;const n=s.length?`${s.slice(0,5).join(", ")}${s.length>5?", …":""} `+(s.length>1?"are":"is")+" not":"",o=r.length?"some of the members it holds are not":"";throw new l(`The members of team mailbox ${t} must have an address at ${e}: `+[n,o].filter(Boolean).join(", and "))}teamMailboxUrl(t){const e=t.toLowerCase(),a=e.lastIndexOf("@");return`${this.webadminUrl}/domains/${e.slice(a+1)}/team-mailboxes/${encodeURIComponent(e.slice(0,a))}`}async teamMailboxMembers(t,e,a,i){if(!i.length)return;const s=this.teamMailboxUrl(e),r=await this.getMemberEmails(i);await Promise.all(r.map(i=>this.callWebAdminApi("teamMailbox:"+("PUT"===a?"addMember":"removeMember"),`${s}/members/${i}`,a,t,null,{teamMailbox:e,memberMail:i})))}getDisplayNameFromAttributes(t){const e=this.attributeToString(t[this.displayNameAttr]);if(e)return e;const a=this.attributeToString(t.cn);if(a)return a;const i=this.attributeToString(t.givenName),s=this.attributeToString(t.sn);if(i||s){const t=[];return i&&t.push(i),s&&t.push(s),t.join(" ")}const r=this.attributeToString(t[this.mailAttr]);return r||null}async isGroupDn(t){const e=await this.ldapGetAttributes(t,["objectClass"]);return isGroup(e?.objectClass)}async getMailFromDN(t){const e=await this.ldapGetAttributes(t,[this.mailAttr]);return e?this.attributeToString(e[this.mailAttr]):null}async getDisplayNameFromDN(t){const e=[this.displayNameAttr,"cn","givenName","sn",this.mailAttr],a=await this.ldapGetAttributes(t,e);return a?this.getDisplayNameFromAttributes(a):null}async generateSignature(t){const e=this.config.james_signature_template;if(!e)return null;const a=await this.ldapGetAttributes(t);if(!a)return null;let i=e;return i=i.replace(/\{(\w+)\}/g,(t,e)=>"string"==typeof e&&a&&"object"==typeof a&&Object.prototype.hasOwnProperty.call(a,e)&&this.attributeToString(a[e])||""),i}async updateJamesIdentity(t,e,a,i={}){const s=(this.identityQueue.get(e)??Promise.resolve()).then(()=>this.doUpdateJamesIdentity(t,e,a,i));this.identityQueue.set(e,s);try{await s}finally{this.identityQueue.get(e)===s&&this.identityQueue.delete(e)}}async doUpdateJamesIdentity(t,e,a,i){const s={plugin:this.name,event:"onLdapDisplayNameChange",result:"error",dn:t,mail:e,displayName:a};for(let r=1;;r++){try{const n=`${this.webadminUrl}/users/${e}/identities`,o=await this.requestLimit(()=>this.fetch(`${n}?default=true`,{method:"GET",headers:this.createHeaders()}));let l;if(o.ok)l=pickDefaultIdentity(await o.json());else if(404!==o.status){const t="get_default_identity";if(this.shouldRetry(o.status,g,r)){await this.warnAndWait(s,t,r,o);continue}return void this.logger.error({...s,step:t,...r>1?{attempts:r}:{},http_status:o.status,http_status_text:o.statusText})}const d=await this.generateSignature(t),m=l?.id?{id:l.id,email:l.email,name:a}:{email:e,name:a,sortOrder:0};d?m.htmlSignature=d:Object.assign(m,i);const h=l?.id?"update_identity":"create_identity",u=await this.requestLimit(()=>this.fetch(l?.id?`${n}/${l.id}`:n,{method:l?.id?"PUT":"POST",headers:this.createHeaders("application/json"),body:JSON.stringify(m)}));if(u.ok)this.logger.info({...s,step:h,result:"success",http_status:u.status});else{if(this.shouldRetry(u.status,g,r)){await this.warnAndWait(s,h,r,u);continue}this.logger.error({...s,step:h,...r>1?{attempts:r}:{},http_status:u.status,http_status_text:u.statusText})}}catch(t){this.logger.error({...s,error:`${t}`})}return}}async defaultSignatures(t){try{const e=await this.requestLimit(()=>this.fetch(`${this.webadminUrl}/users/${t}/identities?default=true`,{method:"GET",headers:this.createHeaders()}));if(!e.ok)return{};const a=pickDefaultIdentity(await e.json()),i={};return a?.htmlSignature&&(i.htmlSignature=a.htmlSignature),a?.textSignature&&(i.textSignature=a.textSignature),i}catch(e){return this.logger.debug(`Could not read the identities of ${t}: ${e}`),{}}}ownForwards(t){return this.config.james_sync_forwards?this.getAliases(t):[]}async dropStaleForwards(t,e,a){let i;try{const s=await this.requestLimit(()=>this.fetch(`${this.webadminUrl}/address/forwards/${e}`,{method:"GET",headers:this.createHeaders()}));if(!s.ok)return void(404!==s.status&&this.logger.warn({plugin:this.name,event:"dropStaleForward",result:"error",dn:t,address:e,step:"list_forwards",http_status:s.status,http_status_text:s.statusText}));const r=a.map(t=>t.toLowerCase());i=(await s.json()).map(t=>t.mailAddress).filter(t=>t&&!r.includes(t.toLowerCase()))}catch(t){return void this.logger.debug(`Could not read the forwards of ${e}: ${t}`)}await Promise.all(i.map(a=>this.callWebAdminApi("dropStaleForward",`${this.webadminUrl}/address/forwards/${e}/targets/${a}`,"DELETE",t,null,{address:e,forward:a})))}async warnAndWait(t,e,a,i){this.logger.warn({...t,step:e,result:"retry",attempt:a,http_status:i.status,http_status_text:i.statusText}),await this.waitBeforeRetry(a)}async deleteUserData(t,e){const a={plugin:this.name,event:"deleteUserData",mail:t,fromStep:e};try{const i=new URL(`${this.webadminUrl}/users/${t}`);i.searchParams.set("action","deleteData"),e&&i.searchParams.set("fromStep",e);const s=await this.requestLimit(()=>this.fetch(i.toString(),{method:"POST",headers:this.createHeaders()}));if(!s.ok)return this.logger.error({...a,http_status:s.status,http_status_text:s.statusText}),null;const r=await s.json();return this.logger.info({...a,http_status:s.status,taskId:r.taskId}),r}catch(t){return this.logger.error({...a,error:`${t}`}),null}}getMailboxType(t){const e=t[this.mailboxTypeAttr];if(!e)return null;const a=Array.isArray(e)?String(e[0]):String(e),i=/^\s*cn\s*=\s*([^,]+)/i.exec(a);if(!i)return null;return{group:"group",mailinglist:"mailingList",teammailbox:"teamMailbox"}[i[1].trim().toLowerCase()]??null}async createTeamMailbox(t,e,a){const i=this.teamMailboxUrl(a);await this.callWebAdminApi("teamMailbox:create",i,"PUT",t,null,{teamMailbox:a}),await this.teamMailboxMembers(t,a,"PUT",members(e))}async getMemberEmails(t){const e=t.filter(t=>!o(t,this.config.group_dummy_user)).map(t=>this.server.ldap.queryLimit(async()=>{const e=await this.ldapGetAttributes(t,[this.mailAttr]);return e?this.attributeToString(e[this.mailAttr]):null}));return(await Promise.all(e)).filter(t=>null!==t)}async getGroupMail(t){const e=await this.ldapGetAttributes(t,["mail"]);return e?this.attributeToString(e.mail):null}async getGroupMailboxType(t){try{const e=await this.server.ldap.system.search({paged:!1,scope:"base",attributes:[this.mailboxTypeAttr]},t);if(e.searchEntries&&e.searchEntries.length>0)return this.getMailboxType(e.searchEntries[0])}catch(e){const a=e instanceof Error?e.message:String(e);this.logger.debug(`Could not get mailbox type for group ${t}: ${a}`)}return null}async _handleDelegationChange(t,e){const a=await this.ldapGetAttributes(t,[this.mailAttr]);if(!a)return void this.logger.warn({plugin:this.name,event:"onLdapChange",dn:t,message:"Could not find user entry to get mail attribute"});const i=a[this.mailAttr];if(!i||"string"!=typeof i)return void this.logger.warn({plugin:this.name,event:"onLdapChange",dn:t,message:"User has no mail attribute, cannot manage delegation"});const s=this.config.delegation_attribute;if(!s)return;const[r,n]=e[s]||[],o=this._normalizeToArray(r),l=this._normalizeToArray(n),d=l.filter(t=>!o.includes(t)),m=o.filter(t=>!l.includes(t)),h=d.map(t=>this.server.ldap.queryLimit(async()=>{const e=await this._getDelegateEmail(t);return{dn:t,email:e}})),u=m.map(t=>this.server.ldap.queryLimit(async()=>{const e=await this._getDelegateEmail(t);return{dn:t,email:e}})),[c,p]=await Promise.all([Promise.all(h),Promise.all(u)]),f=[];for(const{dn:e,email:a}of c)a&&f.push(this.callWebAdminApi("onLdapChange:addDelegation",`${this.webadminUrl}/users/${i}/authorizedUsers/${a}`,"PUT",t,null,{userMail:i,delegateEmail:a,delegateDN:e,action:"add"},g));for(const{dn:e,email:a}of p)a&&f.push(this.callWebAdminApi("onLdapChange:removeDelegation",`${this.webadminUrl}/users/${i}/authorizedUsers/${a}`,"DELETE",t,null,{userMail:i,delegateEmail:a,delegateDN:e,action:"remove"}));await Promise.all(f)}async _getDelegateEmail(t){const e=await this.ldapGetAttributes(t,[this.mailAttr]);return e?this.attributeToString(e[this.mailAttr]):(this.logger.warn({plugin:this.name,event:"getDelegateEmail",dn:t,message:"Could not resolve delegate DN to email"}),null)}_normalizeToArray(t){return t?Array.isArray(t)?t:[t]:[]}}export{James as default};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{TwakePlugin as t}from"../../abstract/twakePlugin.js";import"node-fetch";import"../../lib/metrics.js";import{isDnInBranch as r,asyncHandler as e,normalizeDn as i}from"../../lib/utils.js";import{wantJson as s}from"../../lib/expressFormatedResponses.js";const a=[500,502,503,504];class MigrationProxy extends t{constructor(t){if(super(t,"migration_proxy_url","migration_proxy_token","migration_proxy_concurrency"),this.name="migrationProxy",this.roles=["consistency","api"],this.dependencies={onLdapChange:"core/ldap/onChange"},this.queues=new Map,this.hooks={onLdapEntryChange:(t,e,i)=>{if(this.branches.some(e=>r(t,e)))return this.inOrder(t,()=>this.follow(t,e,i))}},this.branches=this.config.migration_proxy_branch||[],!this.branches.length)throw new Error(`${this.name}: --migration-proxy-branch is required`);this.aliasAttr=this.config.alias_attribute||"mailAlternateAddress",this.retryAttempts="number"==typeof this.config.migration_proxy_retry_attempts?Math.max(1,this.config.migration_proxy_retry_attempts):5,this.retryDelay="number"==typeof this.config.migration_proxy_retry_delay?Math.max(0,this.config.migration_proxy_retry_delay):1e3}health(){return this.webadminHealth()}api(t){const r=this.config.api_prefix||"/api";t.get(`${r}/v1/users/:user/mail-migration`,e((t,r)=>this.getMigrationStatus(t,r)))}inOrder(t,r){let e;try{e=i(t)}catch{e=t.toLowerCase()}const s=(this.queues.get(e)??Promise.resolve()).then(r,r);return this.queues.set(e,s),s.finally(()=>{this.queues.get(e)===s&&this.queues.delete(e)})}addresses(t){if(!t)return{primary:null,all:[]};const r=this.getAliases(t[this.mailAttr]),e=new Set,i=[...r,...this.getAliases(t[this.aliasAttr])].filter(t=>{const r=t.toLowerCase();return!e.has(r)&&(e.add(r),!0)});return{primary:r[0]??null,all:i}}async follow(t,r,e){const i=this.addresses(r),s=this.addresses(e),lower=t=>new Set(t.map(t=>t.toLowerCase())),a=lower(i.all),o=lower(s.all),n=s.all.filter(t=>!a.has(t.toLowerCase())),l=i.all.filter(t=>!o.has(t.toLowerCase()));if(!n.length&&!l.length)return;let h;if(i.primary)if(s.primary){h="onLdapEntryChange:update";const r={plugin:this.name,event:h,dn:t,mail:i.primary,added:n,removed:l};let e;try{e=await this.isMigrated(i.primary,!0)}catch(t){return void this.logger.error({...r,result:"error",error:t instanceof Error?t.message:String(t)})}if(!e)return void this.logger.debug({...r,result:"ignored"})}else h="onLdapEntryChange:delete";else h="onLdapEntryChange:add";(await Promise.all(n.map(r=>this.call(h,t,"PUT",r)))).every(Boolean)&&await Promise.all(l.map(r=>this.call(h,t,"DELETE",r)))}userUrl(t){return`${this.webadminUrl}/migratedUsers/${encodeURIComponent(t)}`}call(t,r,e,i){return this.callWebAdminApi(t,this.userUrl(i),e,r,null,{mail:i},a)}async isMigrated(t,r=!1){for(let e=1;;e++){const i=await this.requestLimit(()=>this.fetch(this.userUrl(t),{method:"HEAD",headers:this.createHeaders(),signal:AbortSignal.timeout(1e4)}));if(404===i.status)return!1;if(i.ok)return!0;if(!r||!this.shouldRetry(i.status,a,e))throw new Error(`Migration proxy answered ${i.status} ${i.statusText}`);await this.waitBeforeRetry(e)}}async getMigrationStatus(t,r){if(!s(t,r))return;const e=t.params.user,i=await this.findReadableUser(e,t,[this.mailAttr]);if(!i)return void r.status(404).json({error:`User ${e} not found`});const a=this.attributeToString(i[this.mailAttr]);if(a)try{r.json({migrated:await this.isMigrated(a)})}catch(t){this.logger.error({plugin:this.name,event:"getMigrationStatus",username:e,error:t instanceof Error?t.message:String(t)}),r.status(502).json({error:"Failed to ask the migration proxy"})}else r.status(400).json({error:`User ${e} has no mail attribute`})}}export{MigrationProxy as default};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{randomUUID as e}from"node:crypto";import t from"../../abstract/plugin.js";import{BadRequestError as s}from"../../lib/errors.js";import{extractLdapCode as a}from"../../lib/ldapCodes.js";import{currentOperation as i}from"../../lib/operation.js";import{parseDn as r,unescapeDnValue as n,escapeDnValue as o,escapeLdapFilter as h}from"../../lib/utils.js";import{organizationIn as c,dnKey as d,movedInto as l,ORG as p,branchPattern as u,values as g,parentOf as m,read as f,rdnValue as w,notFound as b,RouteError as y,invalid as v,bodyOf as k,isDisplayName as O,NAME_RULE as $}from"./groups.js";import{isTombstone as _,valueOf as I,lifecycleAttributes as S}from"./lifecycleAttributes.js";const C=["viewer","editor","admin"],isRole=e=>C.includes(e),M=[...C].reverse(),L="twake.space.sync.requested",q=["name"],j=["uid","displayName","mail","jobTitle","role"],A=new RegExp(`:(${C.join("|")})$`,"i"),conflict=(e,t)=>new y(409,e,t),lastAdmin=()=>conflict("A space keeps at least one admin","LAST_ADMIN"),isAdmin=e=>"member"===e.kind&&"admin"===e.role;function roleOf(e){if(!isRole(e.role))throw v(`role must be one of: ${C.join(", ")}`);return e.role}function checkLength(e,t,s){if(e.length>s)throw v(`${t} cannot exceed ${s} entries per request`)}function namesOf(e,t,s){if(!Array.isArray(e)||0===e.length||!e.every(e=>"string"==typeof e&&e.length>0))throw v(`${t} must be a non-empty array of non-empty strings`);return checkLength(e,t,s),[...new Map(e.map(e=>[e.toLowerCase(),e])).values()]}function holdersOf(e,t,s){const a="id"===t?"groups":"members";if(!Array.isArray(e))throw v(`${a} must be an array of { ${t}, role }`);checkLength(e,a,s);const i=new Set;return e.map(e=>{const{[t]:s,role:r}=e??{};if("string"!=typeof s||0===s.length||!isRole(r))throw v(`${a} must be an array of { ${t}, role }, role one of: ${C.join(", ")}`);if(i.has(s.toLowerCase()))throw v(`${s} is listed twice in ${a}`);return i.add(s.toLowerCase()),{name:s,role:r}})}class TwakeSpaces extends t{constructor(e){if(super(e),this.name="twakeSpaces",this.roles=["api"],this.dependencies={twakeGroups:"core/twake/groups",onLdapChange:"core/ldap/onChange"},this.domains=new Map,this.following=Promise.resolve(),this.publishing=Promise.resolve(),this.unlinking=new Map,this.announced=new Map,this.handing=new Map,this.hooks={ldapaddrequest:([e,t,s])=>{for(const s of Object.values(this.roleAttributes))this.checkHolders(e,I(t,s));return[e,t,s]},ldapmodifyrequest:([e,t,s,a])=>{for(const s of Object.values(this.roleAttributes))for(const a of[t.add,t.replace])a&&this.checkHolders(e,I(a,s));return[e,t,s,a]},ldaprenamerequest:async([e,t,a])=>{const i=l(this.spacePattern,e,t);if(void 0===i)return[e,t,a];const r=Object.values(this.roleAttributes),n=await this.entry(e,r);if(n&&r.some(e=>void 0!==this.foreign(i,I(n,e))))throw new s(`The space holds users or groups of another organization than ${i}`);return[e,t,a]},ldapdeleterequest:async([e,t])=>{const s=i();if(void 0!==s)for(const t of[e].flat()){const e=c(this.userPattern,t);if(void 0!==e){if(this.tombstone?.keeps(t))continue;const a=await this.administered(e,t);if(!a.length)continue;this.handing.has(s)||this.handing.set(s,new Map),this.handing.get(s).set(d(t),{org:e,ids:a})}else if(this.rabbitmq){const e=await this.deletedGroup(t);e&&this.unlinking.set(d(t),{...e,op:s})}}return[e,t]},ldapdeletedone:(e,t={})=>{const s=i(),a=void 0===s?void 0:this.handing.get(s);for(const s of[e].flat()){const e=this.unlinking.get(d(s));e&&delete e.op;const i=a?.get(d(s));i&&this.queue(s,()=>this.handOver(i.org,s,i.ids,t))}},ldapdeleteend:e=>{this.handing.delete(e);for(const[t,s]of this.unlinking)if(s.op===e){this.unlinking.delete(t);for(const{id:e,context:t,fields:a}of s.owed??[])this.publishEvent(s.org,e,"group.unlinked",t,a)}},onLdapEntryChange:(e,t,s,a)=>{const i=this.orgPattern?.exec(r(e).join(","))?.groups;i&&this.rabbitmq&&(this.publishing=this.publishing.then(()=>{this.domains.delete(n(i.org).toLowerCase())})),this.follows&&this.queue(e,()=>this.follow(e,t,s,a));const o=c(this.userPattern,e);void 0!==o&&this.lifecycle.deleted&&t&&s&&!_(t,this.lifecycle)&&_(s,this.lifecycle)&&this.queue(e,async()=>this.handOver(o,e,await this.administered(o,e),a))}},this.spaceBase=this.config.twake_space_base||"",!this.spaceBase.includes(p))throw new Error(`${this.name}: --twake-space-base must hold ${p}`);this.spacePattern=u(this.spaceBase),this.userPattern=u(this.config.twake_group_user_base),this.lifecycle=S(this.config),this.displayName=this.config.twake_space_display_name_attribute||"twakeDisplayName",this.roleAttributes={admin:this.config.twake_space_admin_attribute||"twakeSpaceAdmin",editor:this.config.twake_space_editor_attribute||"twakeSpaceEditor",viewer:this.config.twake_space_viewer_attribute||"twakeSpaceViewer"},this.userRole=this.config.twake_space_user_role_attribute||"",this.exchange=this.config.twake_space_exchange||"space",this.domainAttribute=this.config.twake_instance_organization_domain_attribute||"twakeDomain",this.config.twake_group_organization_dn&&(this.orgPattern=u(this.config.twake_group_organization_dn)),this.config.rabbitmq_url&&(this.dependencies.rabbitmq="core/rabbitmq")}get follows(){return Boolean(this.userRole||this.config.rabbitmq_url)}get groups(){return this.server.loadedPlugins.twakeGroups}get tombstone(){return this.server.loadedPlugins.twakeTombstone}async assertComposition(){if(this.config.rabbitmq_url&&!await(this.rabbitmq?.getRawClient()))throw new Error(`${this.name}: RabbitMQ at --rabbitmq-url cannot be reached`);if(this.config.rabbitmq_url&&this.orgPattern&&!(await this.server.ldap.schemaIndex()).getAttributeType(this.domainAttribute))throw new Error(`${this.name}: the directory schema defines no ${this.domainAttribute}; load a schema that does, or set --twake-instance-organization-domain-attribute`);if(!this.follows||!this.server.loadedPlugins.trash)return;const type=e=>e.split("=")[0].toLowerCase(),e=[this.config.twake_group_base||"",this.spaceBase].map(e=>{const t=r(e).reverse();return e=>r(e).reverse().slice(0,t.length).every((e,s)=>t[s].includes(p)?type(e)===type(t[s]):d(e)===d(t[s]))}),t=String(this.config.trash_watched_bases||"").split(";").map(e=>e.trim()).filter(Boolean);if(0===t.length||t.some(t=>e.some(e=>e(t))))throw new Error(`${this.name}: core/ldap/trash watches the organization groups or spaces, and the deletion of one would not be followed: no user role value or event would say it went. Leave the group and space branches out of --trash-watched-bases`)}queue(e,t){this.following=this.following.then(()=>t().catch(t=>{this.logger.error({plugin:this.name,event:"follow",dn:e,error:String(t)})}))}organizationOf(e){return c(this.spacePattern,e)}spaceBaseOf(e){return this.spaceBase.replace(p,o(e))}spaceDn(e,t){return`cn=${o(t)},${this.spaceBaseOf(e)}`}async entry(e,t){try{return(await this.server.ldap.system.search({paged:!1,scope:"base",attributes:t},e)).searchEntries[0]}catch(e){if(32===a(e))return;throw e}}checkHolders(e,t){const a=this.organizationOf(e);if(!a)return;const i=this.foreign(a,t);if(void 0!==i)throw new s(`${i} is neither a user nor a group of organization ${a}`)}foreign(e,t){const s=[this.groups.userBaseOf(e),this.groups.groupBaseOf(e)].map(d);return g(t).find(e=>!s.includes(d(m(e))))}async api(e){const t=`${this.config.api_prefix}/v1/organizations/:id/spaces`,route=(e,t)=>this.groups.organizationRoute(e,t,this.name),done=e=>{e.json({success:!0})},spaceId=e=>e.params.spaceId;e.get(t,route("listing spaces",(e,t,s)=>this.listRoute(e,t,s))),e.post(t,route("creating space",(e,t,s)=>this.createRoute(e,t,s))),e.get(`${t}/:spaceId`,route("getting space",async(e,t,s)=>{t.json(this.spaceOf(s,await this.space(s,spaceId(e),e)))})),e.patch(`${t}/:spaceId`,route("renaming space",async(e,t,s)=>{const a=k(e),i=Object.keys(a).filter(e=>"name"!==e);if(i.length)throw v(`Only "name" can be updated; unsupported field(s): ${i.join(", ")}`);if(!O(a.name))throw v($);await this.write(s,spaceId(e),{replace:{[this.displayName]:a.name}},e),done(t)})),e.delete(`${t}/:spaceId`,route("deleting space",async(e,t,s)=>{try{await this.server.ldap.delete(this.spaceDn(s,spaceId(e)),e)}catch(e){if(32===a(e))throw b("space");throw e}done(t)})),e.get(`${t}/:spaceId/members`,route("listing space members",async(e,t,s)=>{const a=this.groups.page(e,j),i=await this.space(s,spaceId(e),e),r=i.holders.filter(e=>"member"===e.kind),n=new Map(r.map(e=>[e.name.toLowerCase(),e.role]));t.json({organizationId:s,id:i.id,...await this.groups.memberPage(a,s,r.map(e=>e.name),e=>({role:n.get(e.toLowerCase())}),e)})})),e.post(`${t}/:spaceId/members`,route("adding space members",async(e,t,s)=>{const a=k(e),i=namesOf(a.usernames,"usernames",this.groups.maxPage);await this.add(e,s,"member",roleOf(a),()=>this.liveUsers(s,i,e)),done(t)})),e.patch(`${t}/:spaceId/members/:userId`,route("changing space member role",async(e,t,s)=>{await this.change(e,s,"member",roleOf(k(e))),done(t)})),e.delete(`${t}/:spaceId/members/:userId`,route("removing space member",async(e,t,s)=>{await this.change(e,s,"member"),done(t)})),e.get(`${t}/:spaceId/groups`,route("listing space groups",async(e,t,s)=>{const a=await this.space(s,spaceId(e),e),i=a.holders.filter(e=>"group"===e.kind),r=new Map((await this.orgGroups(s,i.map(e=>e.name),[],e)).map(e=>[d(e.dn),this.groups.groupOf(s,e).displayName])),n=i.map(({name:e,role:t,dn:s})=>({id:e,name:r.get(d(s))??e,role:t})).sort((e,t)=>e.name.localeCompare(t.name));t.json({organizationId:s,id:a.id,groups:n})})),e.post(`${t}/:spaceId/groups`,route("linking space groups",async(e,t,s)=>{const a=k(e),i=namesOf(a.groupIds,"groupIds",this.groups.maxPage);await this.add(e,s,"group",roleOf(a),()=>this.groupDns(s,i,e)),done(t)})),e.patch(`${t}/:spaceId/groups/:groupId`,route("changing space group role",async(e,t,s)=>{await this.change(e,s,"group",roleOf(k(e))),done(t)})),e.delete(`${t}/:spaceId/groups/:groupId`,route("unlinking space group",async(e,t,s)=>{await this.change(e,s,"group"),done(t)})),await(this.rabbitmq?.subscribe(this.exchange,L,this.config.twake_space_sync_queue||`${L}.ldap-rest`,e=>this.onSyncRequested(e)))}spaceOf(e,t){return{id:t.id,name:t.name,organizationId:e,members:t.holders.filter(e=>"member"===e.kind).map(({name:e,role:t})=>({username:e,role:t})),groups:t.holders.filter(e=>"group"===e.kind).map(({name:e,role:t})=>({id:e,role:t}))}}entryOf(e,t,s){const a=new Map([[d(this.groups.userBaseOf(e)),"member"],[d(this.groups.groupBaseOf(e)),"group"]]),i=f(t,"cn")||"",r=new Map;for(const e of M)for(const i of g(I(t,this.roleAttributes[e]))){const t=d(i),n=a.get(d(m(i)));if(!n||s.has(t))continue;const o=r.get(t);o?o.roles.push(e):r.set(t,{kind:n,name:w(i),role:e,roles:[e],dn:i})}return{id:i,name:f(t,this.displayName)??i,holders:[...r.values()]}}async spaces(e,t,s,i,r){const[n,o]=await Promise.all([(async()=>{try{return(await this.server.ldap.search({paged:!1,scope:void 0===s?"one":"base",filter:t,attributes:["cn",this.displayName,...Object.values(this.roleAttributes)]},void 0===s?this.spaceBaseOf(e):this.spaceDn(e,s),i)).searchEntries}catch(e){if(32===a(e))return[];throw e}})(),r??this.hidden(e)]);return n.map(t=>this.entryOf(e,t,o))}async hidden(e){return new Set((await this.groups.tombstonesOf(e)).map(d))}holding(e){return`(|${e.flatMap(e=>Object.values(this.roleAttributes).map(t=>`(${t}=${h(e)})`)).join("")})`}async space(e,t,s){const[a]=await this.spaces(e,"(objectClass=*)",t,s);if(!a)throw b("space");return a}async write(e,t,s,i){try{await this.server.ldap.modify(this.spaceDn(e,t),s,i)}catch(e){if(32===a(e))throw b("space");throw e}}async again(e){for(let t=0;;t++)try{return await e()}catch(e){const s=a(e);if(16!==s&&20!==s||t>=2)throw e}}async liveUsers(e,t,s){const a=this.groups.userAttribute,i=new Map((await this.groups.users(e,t,[a],s)).map(e=>[(f(e,a)||"").toLowerCase(),e.dn]));if(t.some(e=>!i.has(e.toLowerCase())))throw b("user");return i}get groupName(){return this.config.twake_group_display_name_attribute||"twakeDisplayName"}async orgGroups(e,t,s=[],a){const i=this.groups.cn,r=[];for(let n=0;n<t.length;n+=this.groups.maxPage){const o=t.slice(n,n+this.groups.maxPage);r.push(...await this.groups.groups(e,`(|${o.map(e=>`(${i}=${h(e)})`).join("")})`,[i,this.groupName,...s],a))}return r}async follow(e,t,s,a){let i=this.organizationOf(e);if(void 0!==i){const[e,r]=[t,s].map(e=>e?this.entryOf(i,e,new Set):void 0),n=[...new Set([e,r].flatMap(e=>e?[e.id]:[]))],o=[e,r].map(e=>new Map(e?.holders.map(e=>[d(e.dn),e.role]))),h=[e,r].flatMap(e=>e?.holders??[]).filter(e=>n.length>1||o[0].get(d(e.dn))!==o[1].get(d(e.dn))),c=new Set(h.filter(e=>"group"===e.kind).map(e=>e.name)),l=await this.orgGroups(i,[...c],["member"]),p=(r??e).id,u=await this.settle(i,[...h.filter(e=>"member"===e.kind).map(e=>e.dn),...l.flatMap(e=>g(e.member))],{id:p,space:e},await this.hidden(i));return void(this.rabbitmq&&await this.announce(i,u,a,n,{was:e,is:r,linked:l}))}if(i=this.groups.organizationOf(e),void 0===i||!t)return;const r=g(t.member),n=g(s?.member),[o,h]=[r,n].map(e=>new Set(e.map(d))),c=s?void 0:this.unlinking.get(d(e));c&&this.unlinking.delete(d(e));const l=await this.hidden(i),p=await this.settle(i,[...r.filter(e=>!h.has(d(e))),...n.filter(e=>!o.has(d(e)))],{group:e,members:o,spaces:c?.spaces},l);if(!this.rabbitmq||!c&&!p.length)return;const u=(await this.spaces(i,this.holding([e]),void 0,void 0,l)).map(e=>e.id);c&&this.unlinked(e,c,u,a),await this.announce(i,p,a,[...c?.spaces.map(e=>e.id)??[],...u])}async administered(e,t){try{const s=`(${this.roleAttributes.admin}=${h(t)})`;return(await this.spaces(e,s)).map(e=>e.id)}catch(e){return this.logger.error({plugin:this.name,event:"handOver",dn:t,error:String(e)}),[]}}async handOver(e,t,s,a){for(const i of s)try{await this.again(()=>this.handOverOnce(e,t,i,a))}catch(e){this.logger.error({plugin:this.name,event:"handOver",space:i,error:String(e)})}}async handOverOnce(e,t,s,a){const i=d(t),[r]=await this.spaces(e,"(objectClass=*)",s),n=r?.holders.filter(e=>"member"===e.kind&&d(e.dn)!==i)??[];if(!r||n.some(e=>"admin"===e.role))return;if(!n.length)return void await this.server.ldap.system.delete(this.spaceDn(e,s),void 0,{context:a});const o=n.some(e=>"editor"===e.role)?"editor":"viewer",h=n.filter(e=>e.role===o).map(e=>e.dn);await this.server.ldap.system.modify(this.spaceDn(e,s),{delete:{[this.roleAttributes[o]]:h},add:{[this.roleAttributes.admin]:h}},void 0,{context:a})}async deletedGroup(e){const t=this.groups.organizationOf(e);if(void 0!==t)try{const s=(await this.spaces(t,this.holding([e]))).flatMap(t=>{const s=t.holders.find(t=>d(t.dn)===d(e));return s?[{id:t.id,role:s.role}]:[]});if(!s.length)return;const[a]=await this.orgGroups(t,[w(e)]),i=w(e);return{org:t,id:i,name:a&&f(a,this.groupName)||i,spaces:s}}catch(t){return void this.logger.error({plugin:this.name,event:"deleting",dn:e,error:String(t)})}}unlinked(e,t,s,a){const{org:i,id:r,name:n,spaces:o}=t,h=new Set(s.map(e=>e.toLowerCase()));for(const{id:t,role:s}of o){const o=d(this.spaceDn(i,t));h.has(t.toLowerCase())&&(this.announced.has(o)||this.announced.set(o,new Set),this.announced.get(o).add(d(e))),this.publishEvent(i,t,"group.unlinked",a,{groups:[{id:r,name:n,role:s}]})}}async settle(e,t,s,a){const i=d(this.groups.userBaseOf(e)),r=new Map(t.filter(e=>d(m(e))===i).map(e=>[d(e),e])),n=[];for(const[t,i]of r)if(!a.has(t))try{const t=await this.rolesOf(e,i,a),r=this.userRole?await this.writeRoles(i,t):await this.rolesOf(e,i,a,s);r&&n.push({dn:i,before:r,after:t})}catch(e){this.logger.error({plugin:this.name,event:"settle",dn:i,error:String(e)})}return n}async userIn(e,t,s,a){const i=(await this.groups.groups(e,`(member=${h(t)})`,[this.groups.cn],s)).map(e=>d(e.dn)),r=new Set([d(t),...i]),n=[t,...i];if(a&&"group"in a){const e=d(a.group);r.delete(e),a.members.has(d(t))&&r.add(e),n.push(a.group)}return{filter:this.holding(n),roleIn:e=>e.holders.find(e=>r.has(d(e.dn)))?.role}}async announce(e,t,s,a,i){const r=new Set;if(i){const{was:a,is:n,linked:o}=i,h=(n??a).id,c=new Map(o.map(e=>[d(e.dn),f(e,this.groupName)||w(e.dn)])),groupOf=e=>({id:e.name,name:c.get(d(e.dn))??e.name,role:e.role}),publish=(t,a)=>this.publishEvent(e,h,t,s,a),l=d(this.spaceDn(e,h)),p=new Set;for(const t of[a,n]){const s=t&&d(this.spaceDn(e,t.id));for(const e of s&&this.announced.get(s)||[])p.add(e);s&&this.announced.delete(s)}if(a&&n){a.name!==n.name&&publish("updated",{name:n.name});const e=new Map(a.holders.filter(e=>"group"===e.kind).map(e=>[d(e.dn),e]));for(const t of n.holders.filter(e=>"group"===e.kind)){const s=e.get(d(t.dn));e.delete(d(t.dn)),s?.role!==t.role&&publish(s?"group.role.changed":"group.linked",{groups:[groupOf(t)]})}const byDeletion=e=>{if(p.delete(d(e.dn)))return!0;const t=this.unlinking.get(d(e.dn));return!!t?.spaces.some(e=>e.id.toLowerCase()===h.toLowerCase())&&(void 0!==t.op&&(t.owed??(t.owed=[])).push({id:h,context:s,fields:{groups:[groupOf(e)]}}),!0)};for(const t of e.values())byDeletion(t)||publish("group.unlinked",{groups:[groupOf(t)]});const[t,i]=[a,n].map(e=>new Set(e.holders.map(e=>d(e.dn)))),r=[...p].filter(e=>t.has(e)&&i.has(e));r.length&&this.announced.set(l,new Set(r))}else if(r.add(h.toLowerCase()),n){const s=t.flatMap(e=>{const t=e.after.get(h);return t?[{dn:e.dn,role:t}]:[]}),a=await this.profiles(e,s.map(e=>e.dn));publish("created",{name:n.name,members:s.flatMap(({dn:e,role:t})=>{const s=a.get(d(e));return s?[{...s,role:t}]:[]}),groups:n.holders.filter(e=>"group"===e.kind).map(groupOf)})}else publish("deleted")}const n=new Set(a.map(e=>e.toLowerCase())),o=t.flatMap(({dn:e,before:t,after:s})=>[...new Set([...t.keys(),...s.keys()])].flatMap(a=>{const[i,o]=[t.get(a),s.get(a)];if(i===o||r.has(a.toLowerCase()))return[];const h=!n.has(a.toLowerCase());if(h&&!this.userRole)return[];return[{dn:e,id:a,event:i?o?"member.role.changed":"member.removed":"member.added",role:o??i,caught:h}]}));if(!o.length)return;const h=await this.profiles(e,o.map(e=>e.dn));for(const{dn:t,id:a,event:i,role:r,caught:n}of o){const o=h.get(d(t));o&&this.publishEvent(e,a,i,n?{}:s,{members:[{...o,role:r}]})}}async profiles(e,t){if(!t.length)return new Map;const s=this.groups.userAttribute,a=this.config.mail_attribute||"mail",i=new Map((await this.groups.users(e,[...new Set(t.map(w))],[s,"entryUUID",a,"givenName","sn"])).map(e=>[(f(e,s)||"").toLowerCase(),e]));return new Map(t.flatMap(e=>{const t=i.get(w(e).toLowerCase());if(!t)return[];const r={uuid:f(t,"entryUUID"),username:f(t,s),email:f(t,a),firstName:f(t,"givenName"),lastName:f(t,"sn")};return[[d(e),r]]}))}publishEvent(e,t,s,a,i={}){this.enqueue(e,s,{id:t,...i,actor:a.actor},(new Date).toISOString())}enqueue(e,t,s,a){const i=this.rabbitmq;if(!i)return Promise.resolve();const r=this.publishing.then(async()=>this.publish(i,t,{organizationId:e,...await this.domainOf(e),...s,timestamp:a}));return this.publishing=r.catch(()=>{}),r}async onSyncRequested(t){const malformed=()=>new Error(`${this.name}: malformed sync request ${JSON.stringify(t)}`);if("object"!=typeof t||!t||Array.isArray(t))throw malformed();const{organizationId:s,id:a}=t,valid=e=>void 0===e||"string"==typeof e&&e.length>0;if(!valid(s)||!valid(a)||void 0===s&&void 0!==a)throw malformed();if(void 0===s){for(const t of await this.organizations())await this.rabbitmq.publish(this.exchange,L,{organizationId:t,timestamp:(new Date).toISOString()},{messageId:e()});return}let i=!1;try{await this.groups.checkOrganization(s)}catch(e){if(!(e instanceof y))throw e;if(this.logger.info({plugin:this.name,event:"sync",organization:s,result:e.code}),410!==e.status)return;i=!0}await this.sync(s,a,i)}async organizations(){const e=r(this.spaceBase),t=e.findIndex(e=>e.includes(p)),s=e.slice(0,t),a=[],i=await this.server.ldap.system.search({paged:{pageSize:500},scope:"one",filter:"(objectClass=*)",attributes:["dn"]},e.slice(t+1).join(","));for await(const e of i)for(const{dn:t}of e.searchEntries){const e=c(this.spacePattern,["cn=x",...s,t].join(","));void 0!==e&&a.push(e)}return a}async sync(e,t,s){const a=(new Date).toISOString(),i=s?[]:await this.spaces(e,"(objectClass=*)",t),r=[];void 0===t||i.length||r.push(this.enqueue(e,"deleted",{id:t},a));for(const t of i)r.push(this.enqueue(e,"synced",await this.whole(e,t),a));void 0===t&&r.push(this.enqueue(e,"sync.completed",{spaceIds:i.map(e=>e.id)},a));const n=(await Promise.allSettled(r)).find(e=>"rejected"===e.status);if(n)throw n.reason}async whole(e,t){const s=d(this.groups.userBaseOf(e)),a=await this.orgGroups(e,t.holders.filter(e=>"group"===e.kind).map(e=>e.name),["member"]),i=new Map(a.map(e=>[d(e.dn),{name:f(e,this.groupName)||w(e.dn),members:g(e.member)}])),r=new Map,hold=(e,t)=>{r.has(d(e))||r.set(d(e),{dn:e,role:t})};for(const e of t.holders)if("member"===e.kind)hold(e.dn,e.role);else for(const t of i.get(d(e.dn))?.members??[])d(m(t))===s&&hold(t,e.role);const n=[...r.values()],o=await this.profiles(e,n.map(e=>e.dn));return{id:t.id,name:t.name,members:n.flatMap(({dn:e,role:t})=>{const s=o.get(d(e));return s?[{...s,role:t}]:[]}),groups:t.holders.filter(e=>"group"===e.kind).map(e=>({id:e.name,name:i.get(d(e.dn))?.name??e.name,role:e.role}))}}async domainOf(e){const t=this.config.twake_group_organization_dn;if(!t)return{};const s=e.toLowerCase();let a=this.domains.get(s);if(void 0===a){const i=this.domainAttribute;try{const s=await this.entry(t.replace(p,o(e)),[i]);a=s&&f(s,i)}catch(t){this.logger.warn({plugin:this.name,event:"organizationDomain",organization:e,error:String(t)})}a&&this.domains.set(s,a)}return a?{organizationDomain:a}:{}}get rabbitmq(){return this.config.rabbitmq_url?this.requirePlugin("rabbitmq"):null}async publish(t,s,a){const i={plugin:this.name,exchange:this.exchange,routingKey:`twake.space.${s}`,messageId:e()};if(!await t.getRawClient())throw this.logger.error({...i,result:"no broker"}),new Error(`${this.name}: no broker for ${i.routingKey}`);try{await t.publish(i.exchange,i.routingKey,a,{messageId:i.messageId})}catch(e){throw this.logger.error({...i,result:"error",error:String(e)}),e}this.logger.info({...i,result:"published"})}async rolesOf(e,t,s,a){const{filter:i,roleIn:r}=await this.userIn(e,t,void 0,a);let n=await this.spaces(e,i,void 0,void 0,s);if(a&&"id"in a){const e=a.id.toLowerCase();n=n.filter(t=>t.id.toLowerCase()!==e),a.space&&n.push(a.space)}const o=new Map(n.flatMap(e=>{const t=r(e);return t?[[e.id,t]]:[]}));if(a&&"group"in a&&a.members.has(d(t)))for(const{id:e,role:t}of a.spaces??[]){const s=o.get(e);(!s||C.indexOf(t)>C.indexOf(s))&&o.set(e,t)}return o}async writeRoles(e,t){const s=this.userRole,i=[...t].map(([e,t])=>`${e}:${t}`),r=new Set(i.map(e=>e.toLowerCase()));for(let t=0;;t++)try{const[t]=(await this.server.ldap.system.search({paged:!1,scope:"base",attributes:[s]},e)).searchEntries;if(!t)return;const a=g(I(t,s)),n=new Set(a.map(e=>e.toLowerCase())),o=a.filter(e=>A.test(e)&&!r.has(e.toLowerCase())),h=i.filter(e=>!n.has(e.toLowerCase()));return(o.length||h.length)&&await this.server.ldap.system.modify(e,{...o.length&&{delete:{[s]:o}},...h.length&&{add:{[s]:h}}}),new Map(a.flatMap(e=>{const t=A.exec(e)?.[1].toLowerCase();return t?[[e.slice(0,e.lastIndexOf(":")),t]]:[]}))}catch(e){const s=a(e);if(32===s)return;if(16!==s&&20!==s||t>=2)throw e}}async groupDns(e,t,s){const a=new Map((await this.orgGroups(e,t,[],s)).map(e=>[(f(e,this.groups.cn)||"").toLowerCase(),e.dn]));if(t.some(e=>!a.has(e.toLowerCase())))throw b("group");return a}async add(e,t,s,a,i){await this.again(()=>this.addOnce(e,t,s,a,i))}async addOnce(e,t,s,a,i){const r=await this.space(t,e.params.spaceId,e),n=await i(),o=new Map(r.holders.filter(e=>e.kind===s).map(e=>[e.name.toLowerCase(),e.role])),h=[];for(const[e,t]of n){const i=o.get(e);if(void 0===i)h.push(t);else if(i!==a)throw"member"===s?conflict(`${e} is already a member, as ${i}`,"MEMBER_EXISTS"):conflict(`${e} is already linked, as ${i}`,"GROUP_ALREADY_LINKED")}h.length&&await this.write(t,r.id,{add:{[this.roleAttributes[a]]:h}},e)}async change(e,t,s,a){await this.again(()=>this.changeOnce(e,t,s,a))}async changeOnce(e,t,s,a){const i=await this.space(t,e.params.spaceId,e),r="member"===s?e.params.userId:e.params.groupId,n=i.holders.find(e=>e.kind===s&&e.name.toLowerCase()===r.toLowerCase());if(!n)throw b(s);if(1===n.roles.length&&n.role===a)return;const o="member"===s&&"admin"===n.role&&"admin"!==a;if(o&&!i.holders.some(e=>e!==n&&isAdmin(e)))throw lastAdmin();const h=Object.fromEntries(n.roles.filter(e=>e!==a).map(e=>[this.roleAttributes[e],n.dn])),c=a&&!n.roles.includes(a)?{[this.roleAttributes[a]]:n.dn}:void 0;if(await this.write(t,i.id,{delete:h,add:c},e),o&&!(await this.space(t,i.id,e)).holders.some(isAdmin))throw await this.write(t,i.id,{add:h,delete:c},e),lastAdmin()}async listRoute(e,t,s){const a=this.groups.page(e,q);let i="(cn=*)";a.search&&(i=`(&${i}(${this.displayName}=*${h(a.search)}*))`);const r=e.query.user;let n;if(void 0!==r){if("string"!=typeof r||!r)throw v("user must be a username");const[t]=(await this.liveUsers(s,[r],e)).values(),a=await this.userIn(s,t,e);i=`(&${i}${a.filter})`,n=a.roleIn}const o=this.groups.sorted((await this.spaces(s,i,void 0,e)).map(e=>({...this.spaceOf(s,e),...n&&{role:n(e)}})),a.sortBy,a.desc);t.json({organizationId:s,spaces:o.slice(a.offset,a.offset+a.limit),pagination:{page:a.page,limit:a.limit,total:o.length,totalPages:Math.ceil(o.length/a.limit)}})}async createRoute(t,s,a){const i=k(t);if(!O(i.name))throw v($);const r=this.groups.maxPage,n=holdersOf(i.members,"username",r),o=holdersOf(i.groups??[],"id",r);if(!n.some(e=>"admin"===e.role))throw v("A space needs an admin among its members","ADMIN_REQUIRED");const[h,c]=await Promise.all([this.liveUsers(a,n.map(e=>e.name),t),this.groupDns(a,o.map(e=>e.name),t)]),d=e(),l={objectClass:this.config.twake_space_class||["top","twakeSpace"],cn:d,[this.displayName]:i.name};for(const[e,t]of[[n,h],[o,c]])for(const{name:s,role:a}of e){const e=this.roleAttributes[a];l[e]=[...g(l[e]),t.get(s.toLowerCase())]}await this.server.ldap.add(this.spaceDn(a,d),l,t),s.status(201).json(this.spaceOf(a,await this.space(a,d,t)))}}export{C as SPACE_ROLES,TwakeSpaces as default};
|
|
1
|
+
import{randomUUID as e}from"node:crypto";import t from"../../abstract/plugin.js";import{BadRequestError as s}from"../../lib/errors.js";import{extractLdapCode as a}from"../../lib/ldapCodes.js";import{currentOperation as i}from"../../lib/operation.js";import{parseDn as r,unescapeDnValue as n,escapeDnValue as o,rdnValue as h,escapeLdapFilter as c}from"../../lib/utils.js";import{organizationIn as d,dnKey as l,movedInto as p,ORG as u,branchPattern as g,withOrg as m,values as f,parentOf as w,read as b,notFound as y,RouteError as v,invalid as k,bodyOf as O,isDisplayName as $,NAME_RULE as _}from"./groups.js";import{isTombstone as I,valueOf as S,lifecycleAttributes as C}from"./lifecycleAttributes.js";const M=["viewer","editor","admin"],isRole=e=>M.includes(e),L=[...M].reverse(),q="twake.space.sync.requested",j=["name"],A=["uid","displayName","mail","jobTitle","role"],z=new RegExp(`:(${M.join("|")})$`,"i"),conflict=(e,t)=>new v(409,e,t),lastAdmin=()=>conflict("A space keeps at least one admin","LAST_ADMIN"),isAdmin=e=>"member"===e.kind&&"admin"===e.role;function roleOf(e){if(!isRole(e.role))throw k(`role must be one of: ${M.join(", ")}`);return e.role}function checkLength(e,t,s){if(e.length>s)throw k(`${t} cannot exceed ${s} entries per request`)}function namesOf(e,t,s){if(!Array.isArray(e)||0===e.length||!e.every(e=>"string"==typeof e&&e.length>0))throw k(`${t} must be a non-empty array of non-empty strings`);return checkLength(e,t,s),[...new Map(e.map(e=>[e.toLowerCase(),e])).values()]}function holdersOf(e,t,s){const a="id"===t?"groups":"members";if(!Array.isArray(e))throw k(`${a} must be an array of { ${t}, role }`);checkLength(e,a,s);const i=new Set;return e.map(e=>{const{[t]:s,role:r}=e??{};if("string"!=typeof s||0===s.length||!isRole(r))throw k(`${a} must be an array of { ${t}, role }, role one of: ${M.join(", ")}`);if(i.has(s.toLowerCase()))throw k(`${s} is listed twice in ${a}`);return i.add(s.toLowerCase()),{name:s,role:r}})}class TwakeSpaces extends t{constructor(e){if(super(e),this.name="twakeSpaces",this.roles=["api"],this.dependencies={twakeGroups:"core/twake/groups",onLdapChange:"core/ldap/onChange"},this.domains=new Map,this.following=Promise.resolve(),this.publishing=Promise.resolve(),this.unlinking=new Map,this.announced=new Map,this.handing=new Map,this.hooks={ldapaddrequest:([e,t,s])=>{for(const s of Object.values(this.roleAttributes))this.checkHolders(e,S(t,s));return[e,t,s]},ldapmodifyrequest:([e,t,s,a])=>{for(const s of Object.values(this.roleAttributes))for(const a of[t.add,t.replace])a&&this.checkHolders(e,S(a,s));return[e,t,s,a]},ldaprenamerequest:async([e,t,a])=>{const i=p(this.spacePattern,e,t);if(void 0===i)return[e,t,a];const r=Object.values(this.roleAttributes),n=await this.entry(e,r);if(n&&r.some(e=>void 0!==this.foreign(i,S(n,e))))throw new s(`The space holds users or groups of another organization than ${i}`);return[e,t,a]},ldapdeleterequest:async([e,t])=>{const s=i();if(void 0!==s)for(const t of[e].flat()){const e=d(this.userPattern,t);if(void 0!==e){if(this.tombstone?.keeps(t))continue;const a=await this.administered(e,t);if(!a.length)continue;this.handing.has(s)||this.handing.set(s,new Map),this.handing.get(s).set(l(t),{org:e,ids:a})}else if(this.rabbitmq){const e=await this.deletedGroup(t);e&&this.unlinking.set(l(t),{...e,op:s})}}return[e,t]},ldapdeletedone:(e,t={})=>{const s=i(),a=void 0===s?void 0:this.handing.get(s);for(const s of[e].flat()){const e=this.unlinking.get(l(s));e&&delete e.op;const i=a?.get(l(s));i&&this.queue(s,()=>this.handOver(i.org,s,i.ids,t))}},ldapdeleteend:e=>{this.handing.delete(e);for(const[t,s]of this.unlinking)if(s.op===e){this.unlinking.delete(t);for(const{id:e,context:t,fields:a}of s.owed??[])this.publishEvent(s.org,e,"group.unlinked",t,a)}},onLdapEntryChange:(e,t,s,a)=>{const i=this.orgPattern?.exec(r(e).join(","))?.groups;i&&this.rabbitmq&&(this.publishing=this.publishing.then(()=>{this.domains.delete(n(i.org).toLowerCase())})),this.follows&&this.queue(e,()=>this.follow(e,t,s,a));const o=d(this.userPattern,e);void 0!==o&&this.lifecycle.deleted&&t&&s&&!I(t,this.lifecycle)&&I(s,this.lifecycle)&&this.queue(e,async()=>this.handOver(o,e,await this.administered(o,e),a))}},this.spaceBase=this.config.twake_space_base||"",2!==this.spaceBase.split(u).length)throw new Error(`${this.name}: --twake-space-base must hold ${u} once`);this.spacePattern=g(this.spaceBase),this.userPattern=g(this.config.twake_group_user_base),this.lifecycle=C(this.config),this.displayName=this.config.twake_space_display_name_attribute||"twakeDisplayName",this.roleAttributes={admin:this.config.twake_space_admin_attribute||"twakeSpaceAdmin",editor:this.config.twake_space_editor_attribute||"twakeSpaceEditor",viewer:this.config.twake_space_viewer_attribute||"twakeSpaceViewer"},this.userRole=this.config.twake_space_user_role_attribute||"",this.exchange=this.config.twake_space_exchange||"space",this.domainAttribute=this.config.twake_instance_organization_domain_attribute||"twakeDomain",this.config.twake_group_organization_dn&&(this.orgPattern=g(this.config.twake_group_organization_dn)),this.config.rabbitmq_url&&(this.dependencies.rabbitmq="core/rabbitmq")}get follows(){return Boolean(this.userRole||this.config.rabbitmq_url)}get groups(){return this.server.loadedPlugins.twakeGroups}get tombstone(){return this.server.loadedPlugins.twakeTombstone}async assertComposition(){if(this.config.rabbitmq_url&&!await(this.rabbitmq?.getRawClient()))throw new Error(`${this.name}: RabbitMQ at --rabbitmq-url cannot be reached`);if(this.config.rabbitmq_url&&this.orgPattern&&!(await this.server.ldap.schemaIndex()).getAttributeType(this.domainAttribute))throw new Error(`${this.name}: the directory schema defines no ${this.domainAttribute}; load a schema that does, or set --twake-instance-organization-domain-attribute`);if(!this.follows||!this.server.loadedPlugins.trash)return;const type=e=>e.split("=")[0].toLowerCase(),e=[this.config.twake_group_base||"",this.spaceBase].map(e=>{const t=r(e).reverse();return e=>r(e).reverse().slice(0,t.length).every((e,s)=>t[s].includes(u)?type(e)===type(t[s]):l(e)===l(t[s]))}),t=String(this.config.trash_watched_bases||"").split(";").map(e=>e.trim()).filter(Boolean);if(0===t.length||t.some(t=>e.some(e=>e(t))))throw new Error(`${this.name}: core/ldap/trash watches the organization groups or spaces, and the deletion of one would not be followed: no user role value or event would say it went. Leave the group and space branches out of --trash-watched-bases`)}queue(e,t){this.following=this.following.then(()=>t().catch(t=>{this.logger.error({plugin:this.name,event:"follow",dn:e,error:String(t)})}))}organizationOf(e){return d(this.spacePattern,e)}spaceBaseOf(e){return m(this.spaceBase,e)}spaceDn(e,t){return`cn=${o(t)},${this.spaceBaseOf(e)}`}async entry(e,t){try{return(await this.server.ldap.system.search({paged:!1,scope:"base",attributes:t},e)).searchEntries[0]}catch(e){if(32===a(e))return;throw e}}checkHolders(e,t){const a=this.organizationOf(e);if(!a)return;const i=this.foreign(a,t);if(void 0!==i)throw new s(`${i} is neither a user nor a group of organization ${a}`)}foreign(e,t){const s=[this.groups.userBaseOf(e),this.groups.groupBaseOf(e)].map(l);return f(t).find(e=>!s.includes(l(w(e))))}async api(e){const t=`${this.config.api_prefix}/v1/organizations/:id/spaces`,route=(e,t)=>this.groups.organizationRoute(e,t,this.name),done=e=>{e.json({success:!0})},spaceId=e=>e.params.spaceId;e.get(t,route("listing spaces",(e,t,s)=>this.listRoute(e,t,s))),e.post(t,route("creating space",(e,t,s)=>this.createRoute(e,t,s))),e.get(`${t}/:spaceId`,route("getting space",async(e,t,s)=>{t.json(this.spaceOf(s,await this.space(s,spaceId(e),e)))})),e.patch(`${t}/:spaceId`,route("renaming space",async(e,t,s)=>{const a=O(e),i=Object.keys(a).filter(e=>"name"!==e);if(i.length)throw k(`Only "name" can be updated; unsupported field(s): ${i.join(", ")}`);if(!$(a.name))throw k(_);await this.write(s,spaceId(e),{replace:{[this.displayName]:a.name}},e),done(t)})),e.delete(`${t}/:spaceId`,route("deleting space",async(e,t,s)=>{try{await this.server.ldap.delete(this.spaceDn(s,spaceId(e)),e)}catch(e){if(32===a(e))throw y("space");throw e}done(t)})),e.get(`${t}/:spaceId/members`,route("listing space members",async(e,t,s)=>{const a=this.groups.page(e,A),i=await this.space(s,spaceId(e),e),r=i.holders.filter(e=>"member"===e.kind),n=new Map(r.map(e=>[e.name.toLowerCase(),e.role]));t.json({organizationId:s,id:i.id,...await this.groups.memberPage(a,s,r.map(e=>e.name),e=>({role:n.get(e.toLowerCase())}),e)})})),e.post(`${t}/:spaceId/members`,route("adding space members",async(e,t,s)=>{const a=O(e),i=namesOf(a.usernames,"usernames",this.groups.maxPage);await this.add(e,s,"member",roleOf(a),()=>this.liveUsers(s,i,e)),done(t)})),e.patch(`${t}/:spaceId/members/:userId`,route("changing space member role",async(e,t,s)=>{await this.change(e,s,"member",roleOf(O(e))),done(t)})),e.delete(`${t}/:spaceId/members/:userId`,route("removing space member",async(e,t,s)=>{await this.change(e,s,"member"),done(t)})),e.get(`${t}/:spaceId/groups`,route("listing space groups",async(e,t,s)=>{const a=await this.space(s,spaceId(e),e),i=a.holders.filter(e=>"group"===e.kind),r=new Map((await this.orgGroups(s,i.map(e=>e.name),[],e)).map(e=>[l(e.dn),this.groups.groupOf(s,e).displayName])),n=i.map(({name:e,role:t,dn:s})=>({id:e,name:r.get(l(s))??e,role:t})).sort((e,t)=>e.name.localeCompare(t.name));t.json({organizationId:s,id:a.id,groups:n})})),e.post(`${t}/:spaceId/groups`,route("linking space groups",async(e,t,s)=>{const a=O(e),i=namesOf(a.groupIds,"groupIds",this.groups.maxPage);await this.add(e,s,"group",roleOf(a),()=>this.groupDns(s,i,e)),done(t)})),e.patch(`${t}/:spaceId/groups/:groupId`,route("changing space group role",async(e,t,s)=>{await this.change(e,s,"group",roleOf(O(e))),done(t)})),e.delete(`${t}/:spaceId/groups/:groupId`,route("unlinking space group",async(e,t,s)=>{await this.change(e,s,"group"),done(t)})),await(this.rabbitmq?.subscribe(this.exchange,q,this.config.twake_space_sync_queue||`${q}.ldap-rest`,e=>this.onSyncRequested(e)))}spaceOf(e,t){return{id:t.id,name:t.name,organizationId:e,members:t.holders.filter(e=>"member"===e.kind).map(({name:e,role:t})=>({username:e,role:t})),groups:t.holders.filter(e=>"group"===e.kind).map(({name:e,role:t})=>({id:e,role:t}))}}entryOf(e,t,s){const a=new Map([[l(this.groups.userBaseOf(e)),"member"],[l(this.groups.groupBaseOf(e)),"group"]]),i=b(t,"cn")||"",r=new Map;for(const e of L)for(const i of f(S(t,this.roleAttributes[e]))){const t=l(i),n=a.get(l(w(i)));if(!n||s.has(t))continue;const o=r.get(t);o?o.roles.push(e):r.set(t,{kind:n,name:h(i),role:e,roles:[e],dn:i})}return{id:i,name:b(t,this.displayName)??i,holders:[...r.values()]}}async spaces(e,t,s,i,r){const[n,o]=await Promise.all([(async()=>{try{return(await this.server.ldap.search({paged:!1,scope:void 0===s?"one":"base",filter:t,attributes:["cn",this.displayName,...Object.values(this.roleAttributes)]},void 0===s?this.spaceBaseOf(e):this.spaceDn(e,s),i)).searchEntries}catch(e){if(32===a(e))return[];throw e}})(),r??this.hidden(e)]);return n.map(t=>this.entryOf(e,t,o))}async hidden(e){return new Set((await this.groups.tombstonesOf(e)).map(l))}holding(e){return`(|${e.flatMap(e=>Object.values(this.roleAttributes).map(t=>`(${t}=${c(e)})`)).join("")})`}async space(e,t,s){const[a]=await this.spaces(e,"(objectClass=*)",t,s);if(!a)throw y("space");return a}async write(e,t,s,i){try{await this.server.ldap.modify(this.spaceDn(e,t),s,i)}catch(e){if(32===a(e))throw y("space");throw e}}async again(e){for(let t=0;;t++)try{return await e()}catch(e){const s=a(e);if(16!==s&&20!==s||t>=2)throw e}}async liveUsers(e,t,s){const a=this.groups.userAttribute,i=new Map((await this.groups.users(e,t,[a],s)).map(e=>[(b(e,a)||"").toLowerCase(),e.dn]));if(t.some(e=>!i.has(e.toLowerCase())))throw y("user");return i}get groupName(){return this.config.twake_group_display_name_attribute||"twakeDisplayName"}async orgGroups(e,t,s=[],a){const i=this.groups.cn,r=[];for(let n=0;n<t.length;n+=this.groups.maxPage){const o=t.slice(n,n+this.groups.maxPage);r.push(...await this.groups.groups(e,`(|${o.map(e=>`(${i}=${c(e)})`).join("")})`,[i,this.groupName,...s],a))}return r}async follow(e,t,s,a){let i=this.organizationOf(e);if(void 0!==i){const[e,r]=[t,s].map(e=>e?this.entryOf(i,e,new Set):void 0),n=[...new Set([e,r].flatMap(e=>e?[e.id]:[]))],o=[e,r].map(e=>new Map(e?.holders.map(e=>[l(e.dn),e.role]))),h=[e,r].flatMap(e=>e?.holders??[]).filter(e=>n.length>1||o[0].get(l(e.dn))!==o[1].get(l(e.dn))),c=new Set(h.filter(e=>"group"===e.kind).map(e=>e.name)),d=await this.orgGroups(i,[...c],["member"]),p=(r??e).id,u=await this.settle(i,[...h.filter(e=>"member"===e.kind).map(e=>e.dn),...d.flatMap(e=>f(e.member))],{id:p,space:e},await this.hidden(i));return void(this.rabbitmq&&await this.announce(i,u,a,n,{was:e,is:r,linked:d}))}if(i=this.groups.organizationOf(e),void 0===i||!t)return;const r=f(t.member),n=f(s?.member),[o,h]=[r,n].map(e=>new Set(e.map(l))),c=s?void 0:this.unlinking.get(l(e));c&&this.unlinking.delete(l(e));const d=await this.hidden(i),p=await this.settle(i,[...r.filter(e=>!h.has(l(e))),...n.filter(e=>!o.has(l(e)))],{group:e,members:o,spaces:c?.spaces},d);if(!this.rabbitmq||!c&&!p.length)return;const u=(await this.spaces(i,this.holding([e]),void 0,void 0,d)).map(e=>e.id);c&&this.unlinked(e,c,u,a),await this.announce(i,p,a,[...c?.spaces.map(e=>e.id)??[],...u])}async administered(e,t){try{const s=`(${this.roleAttributes.admin}=${c(t)})`;return(await this.spaces(e,s)).map(e=>e.id)}catch(e){return this.logger.error({plugin:this.name,event:"handOver",dn:t,error:String(e)}),[]}}async handOver(e,t,s,a){for(const i of s)try{await this.again(()=>this.handOverOnce(e,t,i,a))}catch(e){this.logger.error({plugin:this.name,event:"handOver",space:i,error:String(e)})}}async handOverOnce(e,t,s,a){const i=l(t),[r]=await this.spaces(e,"(objectClass=*)",s),n=r?.holders.filter(e=>"member"===e.kind&&l(e.dn)!==i)??[];if(!r||n.some(e=>"admin"===e.role))return;if(!n.length)return void await this.server.ldap.system.delete(this.spaceDn(e,s),void 0,{context:a});const o=n.some(e=>"editor"===e.role)?"editor":"viewer",h=n.filter(e=>e.role===o).map(e=>e.dn);await this.server.ldap.system.modify(this.spaceDn(e,s),{delete:{[this.roleAttributes[o]]:h},add:{[this.roleAttributes.admin]:h}},void 0,{context:a})}async deletedGroup(e){const t=this.groups.organizationOf(e);if(void 0!==t)try{const s=(await this.spaces(t,this.holding([e]))).flatMap(t=>{const s=t.holders.find(t=>l(t.dn)===l(e));return s?[{id:t.id,role:s.role}]:[]});if(!s.length)return;const[a]=await this.orgGroups(t,[h(e)]),i=h(e);return{org:t,id:i,name:a&&b(a,this.groupName)||i,spaces:s}}catch(t){return void this.logger.error({plugin:this.name,event:"deleting",dn:e,error:String(t)})}}unlinked(e,t,s,a){const{org:i,id:r,name:n,spaces:o}=t,h=new Set(s.map(e=>e.toLowerCase()));for(const{id:t,role:s}of o){const o=l(this.spaceDn(i,t));h.has(t.toLowerCase())&&(this.announced.has(o)||this.announced.set(o,new Set),this.announced.get(o).add(l(e))),this.publishEvent(i,t,"group.unlinked",a,{groups:[{id:r,name:n,role:s}]})}}async settle(e,t,s,a){const i=l(this.groups.userBaseOf(e)),r=new Map(t.filter(e=>l(w(e))===i).map(e=>[l(e),e])),n=[];for(const[t,i]of r)if(!a.has(t))try{const t=await this.rolesOf(e,i,a),r=this.userRole?await this.writeRoles(i,t):await this.rolesOf(e,i,a,s);r&&n.push({dn:i,before:r,after:t})}catch(e){this.logger.error({plugin:this.name,event:"settle",dn:i,error:String(e)})}return n}async userIn(e,t,s,a){const i=(await this.groups.groups(e,`(member=${c(t)})`,[this.groups.cn],s)).map(e=>l(e.dn)),r=new Set([l(t),...i]),n=[t,...i];if(a&&"group"in a){const e=l(a.group);r.delete(e),a.members.has(l(t))&&r.add(e),n.push(a.group)}return{filter:this.holding(n),roleIn:e=>e.holders.find(e=>r.has(l(e.dn)))?.role}}async announce(e,t,s,a,i){const r=new Set;if(i){const{was:a,is:n,linked:o}=i,c=(n??a).id,d=new Map(o.map(e=>[l(e.dn),b(e,this.groupName)||h(e.dn)])),groupOf=e=>({id:e.name,name:d.get(l(e.dn))??e.name,role:e.role}),publish=(t,a)=>this.publishEvent(e,c,t,s,a),p=l(this.spaceDn(e,c)),u=new Set;for(const t of[a,n]){const s=t&&l(this.spaceDn(e,t.id));for(const e of s&&this.announced.get(s)||[])u.add(e);s&&this.announced.delete(s)}if(a&&n){a.name!==n.name&&publish("updated",{name:n.name});const e=new Map(a.holders.filter(e=>"group"===e.kind).map(e=>[l(e.dn),e]));for(const t of n.holders.filter(e=>"group"===e.kind)){const s=e.get(l(t.dn));e.delete(l(t.dn)),s?.role!==t.role&&publish(s?"group.role.changed":"group.linked",{groups:[groupOf(t)]})}const byDeletion=e=>{if(u.delete(l(e.dn)))return!0;const t=this.unlinking.get(l(e.dn));return!!t?.spaces.some(e=>e.id.toLowerCase()===c.toLowerCase())&&(void 0!==t.op&&(t.owed??(t.owed=[])).push({id:c,context:s,fields:{groups:[groupOf(e)]}}),!0)};for(const t of e.values())byDeletion(t)||publish("group.unlinked",{groups:[groupOf(t)]});const[t,i]=[a,n].map(e=>new Set(e.holders.map(e=>l(e.dn)))),r=[...u].filter(e=>t.has(e)&&i.has(e));r.length&&this.announced.set(p,new Set(r))}else if(r.add(c.toLowerCase()),n){const s=t.flatMap(e=>{const t=e.after.get(c);return t?[{dn:e.dn,role:t}]:[]}),a=await this.profiles(e,s.map(e=>e.dn));publish("created",{name:n.name,members:s.flatMap(({dn:e,role:t})=>{const s=a.get(l(e));return s?[{...s,role:t}]:[]}),groups:n.holders.filter(e=>"group"===e.kind).map(groupOf)})}else publish("deleted")}const n=new Set(a.map(e=>e.toLowerCase())),o=t.flatMap(({dn:e,before:t,after:s})=>[...new Set([...t.keys(),...s.keys()])].flatMap(a=>{const[i,o]=[t.get(a),s.get(a)];if(i===o||r.has(a.toLowerCase()))return[];const h=!n.has(a.toLowerCase());if(h&&!this.userRole)return[];return[{dn:e,id:a,event:i?o?"member.role.changed":"member.removed":"member.added",role:o??i,caught:h}]}));if(!o.length)return;const c=await this.profiles(e,o.map(e=>e.dn));for(const{dn:t,id:a,event:i,role:r,caught:n}of o){const o=c.get(l(t));o&&this.publishEvent(e,a,i,n?{}:s,{members:[{...o,role:r}]})}}async profiles(e,t){if(!t.length)return new Map;const s=this.groups.userAttribute,a=this.config.mail_attribute||"mail",i=new Map((await this.groups.users(e,[...new Set(t.map(h))],[s,"entryUUID",a,"givenName","sn"])).map(e=>[(b(e,s)||"").toLowerCase(),e]));return new Map(t.flatMap(e=>{const t=i.get(h(e).toLowerCase());if(!t)return[];const r={uuid:b(t,"entryUUID"),username:b(t,s),email:b(t,a),firstName:b(t,"givenName"),lastName:b(t,"sn")};return[[l(e),r]]}))}publishEvent(e,t,s,a,i={}){this.enqueue(e,s,{id:t,...i,actor:a.actor},(new Date).toISOString())}enqueue(e,t,s,a){const i=this.rabbitmq;if(!i)return Promise.resolve();const r=this.publishing.then(async()=>this.publish(i,t,{organizationId:e,...await this.domainOf(e),...s,timestamp:a}));return this.publishing=r.catch(()=>{}),r}async onSyncRequested(t){const malformed=()=>new Error(`${this.name}: malformed sync request ${JSON.stringify(t)}`);if("object"!=typeof t||!t||Array.isArray(t))throw malformed();const{organizationId:s,id:a}=t,valid=e=>void 0===e||"string"==typeof e&&e.length>0;if(!valid(s)||!valid(a)||void 0===s&&void 0!==a)throw malformed();if(void 0===s){for(const t of await this.organizations())await this.rabbitmq.publish(this.exchange,q,{organizationId:t,timestamp:(new Date).toISOString()},{messageId:e()});return}let i=!1;try{await this.groups.checkOrganization(s)}catch(e){if(!(e instanceof v))throw e;if(this.logger.info({plugin:this.name,event:"sync",organization:s,result:e.code}),410!==e.status)return;i=!0}await this.sync(s,a,i)}async organizations(){const e=r(this.spaceBase),t=e.findIndex(e=>e.includes(u)),s=e.slice(0,t),a=[],i=await this.server.ldap.system.search({paged:{pageSize:500},scope:"one",filter:"(objectClass=*)",attributes:["dn"]},e.slice(t+1).join(","));for await(const e of i)for(const{dn:t}of e.searchEntries){const e=d(this.spacePattern,["cn=x",...s,t].join(","));void 0!==e&&a.push(e)}return a}async sync(e,t,s){const a=(new Date).toISOString(),i=s?[]:await this.spaces(e,"(objectClass=*)",t),r=[];void 0===t||i.length||r.push(this.enqueue(e,"deleted",{id:t},a));for(const t of i)r.push(this.enqueue(e,"synced",await this.whole(e,t),a));void 0===t&&r.push(this.enqueue(e,"sync.completed",{spaceIds:i.map(e=>e.id)},a));const n=(await Promise.allSettled(r)).find(e=>"rejected"===e.status);if(n)throw n.reason}async whole(e,t){const s=l(this.groups.userBaseOf(e)),a=await this.orgGroups(e,t.holders.filter(e=>"group"===e.kind).map(e=>e.name),["member"]),i=new Map(a.map(e=>[l(e.dn),{name:b(e,this.groupName)||h(e.dn),members:f(e.member)}])),r=new Map,hold=(e,t)=>{r.has(l(e))||r.set(l(e),{dn:e,role:t})};for(const e of t.holders)if("member"===e.kind)hold(e.dn,e.role);else for(const t of i.get(l(e.dn))?.members??[])l(w(t))===s&&hold(t,e.role);const n=[...r.values()],o=await this.profiles(e,n.map(e=>e.dn));return{id:t.id,name:t.name,members:n.flatMap(({dn:e,role:t})=>{const s=o.get(l(e));return s?[{...s,role:t}]:[]}),groups:t.holders.filter(e=>"group"===e.kind).map(e=>({id:e.name,name:i.get(l(e.dn))?.name??e.name,role:e.role}))}}async domainOf(e){const t=this.config.twake_group_organization_dn;if(!t)return{};const s=e.toLowerCase();let a=this.domains.get(s);if(void 0===a){const i=this.domainAttribute;try{const s=await this.entry(m(t,e),[i]);a=s&&b(s,i)}catch(t){this.logger.warn({plugin:this.name,event:"organizationDomain",organization:e,error:String(t)})}a&&this.domains.set(s,a)}return a?{organizationDomain:a}:{}}get rabbitmq(){return this.config.rabbitmq_url?this.requirePlugin("rabbitmq"):null}async publish(t,s,a){const i={plugin:this.name,exchange:this.exchange,routingKey:`twake.space.${s}`,messageId:e()};if(!await t.getRawClient())throw this.logger.error({...i,result:"no broker"}),new Error(`${this.name}: no broker for ${i.routingKey}`);try{await t.publish(i.exchange,i.routingKey,a,{messageId:i.messageId})}catch(e){throw this.logger.error({...i,result:"error",error:String(e)}),e}this.logger.info({...i,result:"published"})}async rolesOf(e,t,s,a){const{filter:i,roleIn:r}=await this.userIn(e,t,void 0,a);let n=await this.spaces(e,i,void 0,void 0,s);if(a&&"id"in a){const e=a.id.toLowerCase();n=n.filter(t=>t.id.toLowerCase()!==e),a.space&&n.push(a.space)}const o=new Map(n.flatMap(e=>{const t=r(e);return t?[[e.id,t]]:[]}));if(a&&"group"in a&&a.members.has(l(t)))for(const{id:e,role:t}of a.spaces??[]){const s=o.get(e);(!s||M.indexOf(t)>M.indexOf(s))&&o.set(e,t)}return o}async writeRoles(e,t){const s=this.userRole,i=[...t].map(([e,t])=>`${e}:${t}`),r=new Set(i.map(e=>e.toLowerCase()));for(let t=0;;t++)try{const[t]=(await this.server.ldap.system.search({paged:!1,scope:"base",attributes:[s]},e)).searchEntries;if(!t)return;const a=f(S(t,s)),n=new Set(a.map(e=>e.toLowerCase())),o=a.filter(e=>z.test(e)&&!r.has(e.toLowerCase())),h=i.filter(e=>!n.has(e.toLowerCase()));return(o.length||h.length)&&await this.server.ldap.system.modify(e,{...o.length&&{delete:{[s]:o}},...h.length&&{add:{[s]:h}}}),new Map(a.flatMap(e=>{const t=z.exec(e)?.[1].toLowerCase();return t?[[e.slice(0,e.lastIndexOf(":")),t]]:[]}))}catch(e){const s=a(e);if(32===s)return;if(16!==s&&20!==s||t>=2)throw e}}async groupDns(e,t,s){const a=new Map((await this.orgGroups(e,t,[],s)).map(e=>[(b(e,this.groups.cn)||"").toLowerCase(),e.dn]));if(t.some(e=>!a.has(e.toLowerCase())))throw y("group");return a}async add(e,t,s,a,i){await this.again(()=>this.addOnce(e,t,s,a,i))}async addOnce(e,t,s,a,i){const r=await this.space(t,e.params.spaceId,e),n=await i(),o=new Map(r.holders.filter(e=>e.kind===s).map(e=>[e.name.toLowerCase(),e.role])),h=[];for(const[e,t]of n){const i=o.get(e);if(void 0===i)h.push(t);else if(i!==a)throw"member"===s?conflict(`${e} is already a member, as ${i}`,"MEMBER_EXISTS"):conflict(`${e} is already linked, as ${i}`,"GROUP_ALREADY_LINKED")}h.length&&await this.write(t,r.id,{add:{[this.roleAttributes[a]]:h}},e)}async change(e,t,s,a){await this.again(()=>this.changeOnce(e,t,s,a))}async changeOnce(e,t,s,a){const i=await this.space(t,e.params.spaceId,e),r="member"===s?e.params.userId:e.params.groupId,n=i.holders.find(e=>e.kind===s&&e.name.toLowerCase()===r.toLowerCase());if(!n)throw y(s);if(1===n.roles.length&&n.role===a)return;const o="member"===s&&"admin"===n.role&&"admin"!==a;if(o&&!i.holders.some(e=>e!==n&&isAdmin(e)))throw lastAdmin();const h=Object.fromEntries(n.roles.filter(e=>e!==a).map(e=>[this.roleAttributes[e],n.dn])),c=a&&!n.roles.includes(a)?{[this.roleAttributes[a]]:n.dn}:void 0;if(await this.write(t,i.id,{delete:h,add:c},e),o&&!(await this.space(t,i.id,e)).holders.some(isAdmin))throw await this.write(t,i.id,{add:h,delete:c},e),lastAdmin()}async listRoute(e,t,s){const a=this.groups.page(e,j);let i="(cn=*)";a.search&&(i=`(&${i}(${this.displayName}=*${c(a.search)}*))`);const r=e.query.user;let n;if(void 0!==r){if("string"!=typeof r||!r)throw k("user must be a username");const[t]=(await this.liveUsers(s,[r],e)).values(),a=await this.userIn(s,t,e);i=`(&${i}${a.filter})`,n=a.roleIn}const o=this.groups.sorted((await this.spaces(s,i,void 0,e)).map(e=>({...this.spaceOf(s,e),...n&&{role:n(e)}})),a.sortBy,a.desc);t.json({organizationId:s,spaces:o.slice(a.offset,a.offset+a.limit),pagination:{page:a.page,limit:a.limit,total:o.length,totalPages:Math.ceil(o.length/a.limit)}})}async createRoute(t,s,a){const i=O(t);if(!$(i.name))throw k(_);const r=this.groups.maxPage,n=holdersOf(i.members,"username",r),o=holdersOf(i.groups??[],"id",r);if(!n.some(e=>"admin"===e.role))throw k("A space needs an admin among its members","ADMIN_REQUIRED");const[h,c]=await Promise.all([this.liveUsers(a,n.map(e=>e.name),t),this.groupDns(a,o.map(e=>e.name),t)]),d=e(),l={objectClass:this.config.twake_space_class||["top","twakeSpace"],cn:d,[this.displayName]:i.name};for(const[e,t]of[[n,h],[o,c]])for(const{name:s,role:a}of e){const e=this.roleAttributes[a];l[e]=[...f(l[e]),t.get(s.toLowerCase())]}await this.server.ldap.add(this.spaceDn(a,d),l,t),s.status(201).json(this.spaceOf(a,await this.space(a,d,t)))}}export{M as SPACE_ROLES,TwakeSpaces as default};
|
package/dist/plugins/weblogs.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import e from"../abstract/plugin.js";class WebLogs extends e{constructor(){super(...arguments),this.name="weblogs",this.roles=["logging"]}api(e){e.use((e,r,o)=>{let
|
|
1
|
+
import e from"../abstract/plugin.js";import{requestId as r}from"../lib/changeContext.js";import{runWithLogContext as o}from"../lib/requestContext.js";class WebLogs extends e{constructor(){super(...arguments),this.name="weblogs",this.roles=["logging"]}api(e){e.use((e,r,o)=>{let t=!0;this.logger.debug(`Incoming request: ${e.method} ${e.originalUrl}`);const s=Date.now();r.on("finish",()=>{const o={};e.proxyAuthUser?o.user=e.proxyAuthUser:e.user&&(o.user=e.user),t&&this.log(e,r,s,o),t=!1}),r.on("error",o=>{t&&this.log(e,r,s,{error:o.message}),t=!1}),r.on("close",()=>{r.writableEnded||(t&&this.log(e,r,s,{error:`Connection closed before response was sent for ${e.method} ${e.originalUrl}`}),t=!1)}),o()})}log(e,t,s,n){const i=Date.now()-s,l={...e.logDetails||{},requestId:r(e),method:e.method,url:e.originalUrl,status:t.statusCode,duration:i,ip:this.getClientIp(e),...n};o({requestId:l.requestId},()=>this.logger.notice(l))}getClientIp(e){const r=e.headers["x-forwarded-for"];if(r){return(Array.isArray(r)?r[0]:r).split(",")[0].trim()}return e.socket.remoteAddress||"unknown"}}export{WebLogs as default};
|
|
@@ -12,6 +12,7 @@ import type { DM } from '../bin';
|
|
|
12
12
|
import type ldapActions from '../lib/ldapActions';
|
|
13
13
|
import type { AttributesList, AttributeValue, LdapList, ModifyRequest } from '../lib/ldapActions';
|
|
14
14
|
import type { Schema } from '../config/schema';
|
|
15
|
+
import { EntityValidator } from '../lib/entitySchema/validator';
|
|
15
16
|
import DmPlugin from './plugin';
|
|
16
17
|
/** What {@link LdapFlat.listEntries} is asked for. */
|
|
17
18
|
export interface ListEntriesOptions {
|
|
@@ -220,17 +221,6 @@ export default abstract class LdapFlat extends DmPlugin {
|
|
|
220
221
|
pluralName: string;
|
|
221
222
|
hookPrefix: string;
|
|
222
223
|
constructor(server: DM, config: LdapFlatConfig);
|
|
223
|
-
/**
|
|
224
|
-
* Attributes a client is not allowed to supply: those the server computes
|
|
225
|
-
* (`generated`) and those it only ever derives from elsewhere (`readOnly`,
|
|
226
|
-
* such as `memberOf`, which is driven from the group side).
|
|
227
|
-
*
|
|
228
|
-
* The core and its plugins still write them — the restriction is on the
|
|
229
|
-
* request body, not on the entry.
|
|
230
|
-
*
|
|
231
|
-
* @returns lowercased attribute names
|
|
232
|
-
*/
|
|
233
|
-
protected clientForbiddenAttributes(): Set<string>;
|
|
234
224
|
/**
|
|
235
225
|
* Attributes never sent back over the API. A manager may reset a password
|
|
236
226
|
* without being able to read it back: the asymmetry is a projection, not an
|
|
@@ -324,7 +314,8 @@ export default abstract class LdapFlat extends DmPlugin {
|
|
|
324
314
|
* @returns true when the DN is taken
|
|
325
315
|
*/
|
|
326
316
|
protected rdnExists(value: string): Promise<boolean>;
|
|
327
|
-
|
|
317
|
+
/** The schema's rules; the pointer lookups run as the server */
|
|
318
|
+
protected validator(): EntityValidator;
|
|
328
319
|
/**
|
|
329
320
|
* API routes
|
|
330
321
|
*/
|
|
@@ -432,5 +423,4 @@ export default abstract class LdapFlat extends DmPlugin {
|
|
|
432
423
|
searchEntriesByName(name: string, partial?: boolean, attrs?: string[]): Promise<LdapList>;
|
|
433
424
|
validateNewEntry(dn: string, entry: AttributesList): Promise<boolean>;
|
|
434
425
|
validateChanges(dn: string, changes: ModifyRequest): Promise<boolean>;
|
|
435
|
-
_validateOneChange(field: string, value: AttributeValue | null): Promise<boolean>;
|
|
436
426
|
}
|
|
@@ -3,11 +3,14 @@
|
|
|
3
3
|
* @author Xavier Guimard <xguimard@linagora.com>
|
|
4
4
|
*/
|
|
5
5
|
import type { Express } from 'express';
|
|
6
|
+
import fetch, { type RequestInit, type Response } from 'node-fetch';
|
|
6
7
|
import type winston from 'winston';
|
|
7
8
|
import type { Config, DM } from '../bin';
|
|
8
9
|
import type { Hooks, MaybePromise } from '../hooks';
|
|
10
|
+
import type { HealthStatus } from '../lib/health';
|
|
9
11
|
export { asyncHandler, escapeDnValue, unescapeDnValue, escapeLdapFilter, validateDnValue, } from '../lib/utils';
|
|
10
12
|
export { HttpError, BadRequestError, UnauthorizedError, ForbiddenError, NotFoundError, ConflictError, UriTooLongError, TooManyRequestsError, BadGatewayError, ServiceUnavailableError, GatewayTimeoutError, } from '../lib/errors';
|
|
13
|
+
export type { HealthStatus };
|
|
11
14
|
export type Role = 'auth' | 'authz' | 'protect' | 'api' | 'logging' | 'demo' | 'consistency' | 'configurable';
|
|
12
15
|
export default abstract class DmPlugin {
|
|
13
16
|
/**
|
|
@@ -56,7 +59,24 @@ export default abstract class DmPlugin {
|
|
|
56
59
|
* there, or a service it cannot work without.
|
|
57
60
|
*/
|
|
58
61
|
assertComposition?(): MaybePromise<void>;
|
|
62
|
+
/**
|
|
63
|
+
* Asked by `GET /readyz`: `degraded` when a resource this plugin depends
|
|
64
|
+
* on does not answer while the server still serves. Only the directory
|
|
65
|
+
* makes the server not ready, see `docs/usage/operations.md`.
|
|
66
|
+
*/
|
|
67
|
+
health?(): MaybePromise<HealthStatus>;
|
|
68
|
+
/**
|
|
69
|
+
* Called once by the shutdown, after the server stopped serving: let go
|
|
70
|
+
* of what this plugin holds, a connection, a timer, a queue. See
|
|
71
|
+
* `docs/usage/operations.md#shutdown`.
|
|
72
|
+
*/
|
|
73
|
+
close?(): MaybePromise<void>;
|
|
59
74
|
abstract name: string;
|
|
75
|
+
/**
|
|
76
|
+
* Call another service: `fetch`, timed into the outbound metrics under
|
|
77
|
+
* this plugin's name, see `docs/usage/operations.md#metrics`.
|
|
78
|
+
*/
|
|
79
|
+
protected fetch(url: Parameters<typeof fetch>[0], init?: RequestInit): Promise<Response>;
|
|
60
80
|
/**
|
|
61
81
|
* Constructor
|
|
62
82
|
* @param server DM object
|
|
@@ -1,5 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Abstract base class for Twake integration plugins
|
|
3
|
+
* Provides common HTTP request handling, concurrency control, and LDAP utilities
|
|
4
|
+
* @author Xavier Guimard <xguimard@linagora.com>
|
|
5
|
+
*/
|
|
6
|
+
import type { Request } from 'express';
|
|
1
7
|
import pLimit from 'p-limit';
|
|
2
8
|
import type { DM } from '../bin';
|
|
9
|
+
import { type HealthStatus } from '../lib/health';
|
|
3
10
|
import type { AttributesList } from '../lib/ldapActions';
|
|
4
11
|
import DmPlugin, { type Role } from './plugin';
|
|
5
12
|
/**
|
|
@@ -43,8 +50,23 @@ export declare abstract class TwakePlugin extends DmPlugin {
|
|
|
43
50
|
* @param fields Additional fields for logging
|
|
44
51
|
* @param retryOn HTTP statuses worth another attempt, up to
|
|
45
52
|
* `retryAttempts` in all; none by default
|
|
53
|
+
* @returns whether the call succeeded, an ignored error counting as a
|
|
54
|
+
* success; a failure is logged here, the caller decides what follows
|
|
55
|
+
*/
|
|
56
|
+
protected callWebAdminApi(hookname: string, url: string, method: string, dn: string, body: string | null, fields: object, retryOn?: readonly number[]): Promise<boolean>;
|
|
57
|
+
/**
|
|
58
|
+
* The service's own health check, for the readiness probe: `degraded`
|
|
59
|
+
* when it answers an error, or not under the probe's budget, so that a
|
|
60
|
+
* mute service reads as degraded rather than leaving the probe to time out
|
|
61
|
+
*/
|
|
62
|
+
protected webadminHealth(): Promise<HealthStatus>;
|
|
63
|
+
/**
|
|
64
|
+
* The user a route is asked about, by `uid` across the directory, with
|
|
65
|
+
* `attributes`; null when there is none the caller may read, so that a
|
|
66
|
+
* user the caller cannot read gets the answer a user that is not there
|
|
67
|
+
* gets
|
|
46
68
|
*/
|
|
47
|
-
protected
|
|
69
|
+
protected findReadableUser(username: string, req: Request, attributes: string[]): Promise<AttributesList | null>;
|
|
48
70
|
/**
|
|
49
71
|
* Whether a failed call is worth another attempt
|
|
50
72
|
* @param statusCode HTTP status code of the failure
|