ldap-rest 0.13.4 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/bin/index.js +1 -1
  2. package/dist/config/args.js +1 -1
  3. package/dist/lib/errors.js +1 -1
  4. package/dist/lib/expressFormatedResponses.js +1 -1
  5. package/dist/lib/ldapActions.js +1 -1
  6. package/dist/lib/operation.js +1 -0
  7. package/dist/lib/parseConfig.js +1 -1
  8. package/dist/lib/rawBody.js +1 -1
  9. package/dist/lib/utils.js +1 -1
  10. package/dist/plugins/auth/hmac.js +1 -1
  11. package/dist/plugins/auth/totp.js +1 -1
  12. package/dist/plugins/ldap/onChange.js +1 -1
  13. package/dist/plugins/scim/errors.js +1 -1
  14. package/dist/plugins/scim/scim.js +1 -1
  15. package/dist/plugins/twake/groups.js +1 -1
  16. package/dist/plugins/twake/instances.js +1 -1
  17. package/dist/plugins/twake/lifecycleEvents.js +1 -1
  18. package/dist/plugins/twake/spaces.js +1 -0
  19. package/dist/plugins/twake/tombstone.js +1 -1
  20. package/dist/src/bin/index.d.ts +16 -0
  21. package/dist/src/config/args.d.ts +8 -1
  22. package/dist/src/hooks.d.ts +17 -0
  23. package/dist/src/lib/errors.d.ts +5 -0
  24. package/dist/src/lib/ldapActions.d.ts +12 -1
  25. package/dist/src/lib/operation.d.ts +29 -0
  26. package/dist/src/lib/parseConfig.d.ts +4 -1
  27. package/dist/src/lib/rawBody.d.ts +27 -8
  28. package/dist/src/lib/utils.d.ts +6 -0
  29. package/dist/src/plugins/auth/hmac.d.ts +1 -1
  30. package/dist/src/plugins/ldap/onChange.d.ts +5 -2
  31. package/dist/src/plugins/scim/scim.d.ts +11 -1
  32. package/dist/src/plugins/twake/groups.d.ts +72 -12
  33. package/dist/src/plugins/twake/instances.d.ts +0 -8
  34. package/dist/src/plugins/twake/spaces.d.ts +186 -0
  35. package/dist/src/plugins/twake/tombstone.d.ts +5 -0
  36. package/docs/plugin-development/README.md +21 -0
  37. package/docs/plugin-development/hooks.md +25 -0
  38. package/docs/usage/configuration.md +83 -4
  39. package/docs/usage/plugins/auth/hmac.md +6 -1
  40. package/docs/usage/plugins/integrations/README.md +6 -0
  41. package/docs/usage/plugins/integrations/groups.md +12 -5
  42. package/docs/usage/plugins/integrations/instances.md +0 -10
  43. package/docs/usage/plugins/integrations/lifecycle-events.md +3 -1
  44. package/docs/usage/plugins/integrations/spaces.md +221 -0
  45. package/docs/usage/plugins/ldap/raw.md +1 -1
  46. package/docs/usage/upgrading.md +131 -0
  47. package/package.json +6 -1
  48. package/static/browser/shared/utils/hmac.js +5 -0
  49. package/static/browser/shared/utils/hmac.js.map +1 -1
@@ -1 +1 @@
1
- import{randomUUID as t}from"node:crypto";import{BadRequestError as e,HttpError as s}from"../../lib/errors.js";import{extractLdapCode as r}from"../../lib/ldapCodes.js";import{parseDn as o,unescapeDnValue as i,escapeDnValue as a,isDummyMemberDn as n,escapeLdapFilter as c,normalizeDn as u}from"../../lib/utils.js";import p from"../ldap/groups.js";import{BaseResolver as h}from"../scim/baseResolver.js";import{mergeMapping as d,loadMappingFile as m,DEFAULT_GROUP_MAPPING as g}from"../scim/mapping.js";import{valueOf as l,lifecycleAttributes as f,first as w}from"./lifecycleAttributes.js";const read=(t,e)=>w(l(t,e)),y="{org}",b=["displayName","description","createdAt"],_=["uid","displayName","mail","jobTitle"],O={uid:"uid",_id:"entryUUID",cn:"cn",sn:"sn",givenName:"givenName",displayName:"displayName",mail:"mail",mobile:"mobile",jobTitle:"twakeJobTitle",company:"twakeCompany",organizationRole:"twakeOrganizationRole",organizationId:"twakeOrganizationId"},$={familyName:"sn",givenName:"givenName",additionalName:"twakeAdditionalName",namePrefix:"twakeNamePrefix"},v="twakeIsTechnical",I={organization:"Organization not found",group:"Group not found",user:"User not found",member:"Member not found"};class RouteError extends Error{constructor(t,e,s){super(e),this.status=t,this.code=s}}const notFound=t=>new RouteError(404,I[t],`${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 u(t)}catch{return t.toLowerCase()}}function parentOf(t){return o(t).slice(1).join(",")}class TwakeGroups extends p{constructor(e){super(e),this.name="twakeGroups",this.hooks={ldapaddrequest:([t,e,s])=>(this.checkMembers(t,e.member),void 0!==this.organizationOf(t)&&void 0!==l(e,this.displayName)&&void 0===l(e,this.createdAt)&&(e={...e,[this.createdAt]:(new Date).toISOString()}),[t,e,s]),ldapmodifyrequest:([t,e,s,r])=>(this.checkMembers(t,e.add?.member),this.checkMembers(t,e.replace?.member),[t,e,s,r]),ldapsearchfilter:async([t,e,s])=>[await this.hideTombstones(t),e,s],scimgroupcreate:([e,s,r])=>(r??(r=new h(this.config).groupBase(s)),[this.groupPattern.test(o(r).join(","))?{...e,id:t()}:e,s,r])},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(y))throw new Error(`${this.name}: ${t} must hold ${y}`);const[s,r]=o(this.groupBase).join(",").split(y).map(t=>t.replace(/[.*+?^${}()|[\]\\]/g,"\\$&"));this.groupPattern=new RegExp(`^${s}(?<org>(?:\\\\.|[^,])+)${r}$`,"i"),this.attrs=f(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={...O,...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"}};for(const[s,r]of Object.entries(e)){const e=Object.keys(t.attributes).find(t=>t.toLowerCase()===s.toLowerCase()),o=e?t.attributes[e]:r;e&&delete t.attributes[e],t.attributes[s]=o}return t}afterLoad(){if(!this.server.loadedPlugins.scim)return;const t=this.config.scim_group_rdn_attribute||"cn",e=this.config.scim_group_mapping||"",s=d(g,e?m(e):void 0).entries.find(e=>e.ldap?.toLowerCase()===t.toLowerCase());s&&this.logger.warn(`${this.name}: the SCIM group mapping writes ${s.scim} to ${t}, so SCIM groups keep it as their RDN instead of a generated cn: map ${s.scim} to another attribute with --scim-group-mapping`)}organizationOf(t){const e=o(t).join(","),s=this.groupPattern.exec(parentOf(e));return s?.groups?i(s.groups.org):void 0}groupBaseOf(t){return this.groupBase.replace(y,a(t))}userBaseOf(t){return this.userBase.replace(y,a(t))}checkMembers(t,s){const r=this.organizationOf(t);if(!r)return;const o=dnKey(this.userBaseOf(r));for(const t of values(s))if(!n(t,this.config.group_dummy_user)&&dnKey(parentOf(t))!==o)throw new e(`${t} is not a user of organization ${r}`)}async hideTombstones(t){if(!this.attrs.deleted)return t;const e=new Set;for(const s of t.searchEntries){const t=this.organizationOf(s.dn);t&&void 0!==s.member&&e.add(t)}if(0===e.size)return t;const s=new Set;for(const t of e)for(const e of await this.tombstonesOf(t))s.add(dnKey(e));return 0===s.size?t:{...t,searchEntries:t.searchEntries.map(t=>void 0!==t.member&&this.organizationOf(t.dn)?{...t,member:values(t.member).filter(t=>!s.has(dnKey(t)))}:t)}}async tombstonesOf(t){const{deleted:e,deletedValue:s}=this.attrs;try{const{searchEntries:r}=await this.ldap.search({paged:!1,scope:"one",filter:`(${e}=${c(s)})`,attributes:["dn"]},this.userBaseOf(t));return r.map(t=>t.dn)}catch(t){if(32===r(t))return[];throw t}}api(t){const e=`${this.config.api_prefix}/v1/organizations/:id/groups`,route=(t,e)=>async(r,o)=>{try{const t=r.params.id;await this.checkOrganization(t),await e(r,o,t)}catch(e){if(e instanceof RouteError)return void o.status(e.status).json({error:e.message,code:e.code});if(e instanceof s&&e.statusCode<500)return void o.status(e.statusCode).json({error:e.message,code:400===e.statusCode?"INVALID_INPUT":"REFUSED"});this.logger.error({plugin:this.name,context:t,error:String(e)}),o.status(500).json({error:`Error ${t}`,code:"INTERNAL_ERROR"})}};t.get(e,route("listing groups",(t,e,s)=>this.listRoute(t,e,s))),t.post(e,route("creating group",(t,e,s)=>this.createRoute(t,e,s))),t.get(`${e}/:groupId`,route("getting group",async(t,e,s)=>{const r=await this.readGroup(s,t.params.groupId);if(!r)throw notFound("group");e.json(r)})),t.get(`${e}/:groupId/members`,route("listing group members",(t,e,s)=>this.membersRoute(t,e,s))),t.patch(`${e}/:groupId`,route("updating group",(t,e,s)=>this.updateRoute(t,e,s))),t.delete(`${e}/:groupId`,route("deleting group",async(t,e,s)=>{await this.onGroup(()=>this.deleteGroup(this.groupDn(s,t.params.groupId),t)),e.json({success:!0})})),t.post(`${e}/:groupId/members`,route("adding group members",(t,e,s)=>this.addMembersRoute(t,e,s))),t.delete(`${e}/:groupId/members/:userId`,route("removing group member",(t,e,s)=>this.removeMemberRoute(t,e,s)))}groupDn(t,e){return`${this.cn}=${a(e)},${this.groupBaseOf(t)}`}get userAttribute(){return this.config.ldap_user_main_attribute||"uid"}async checkOrganization(t){if(!this.orgPattern)return;const e=this.config.twake_group_organization_status_attribute||"twakeOrgStatus";let s;try{s=(await this.ldap.search({paged:!1,scope:"base",attributes:[e]},this.orgPattern.replace(y,a(t)))).searchEntries[0]}catch(t){if(32!==r(t))throw t}if(!s)throw notFound("organization");const o=this.config.twake_group_organization_deleted_value||"deleted";if(read(s,e)===o)throw new RouteError(410,"Organization has been deleted","ORGANIZATION_DELETED")}page(t,e){const s=Math.max(1,parseInt(t.query.page)||1),r=Math.max(1,Math.min(parseInt(t.query.limit)||20,this.maxPage)),o=t.query.search;if(void 0!==o&&("string"!=typeof o||o.length<2))throw invalid("Search query must be at least 2 characters","INVALID_SEARCH_QUERY");const i=t.query.sortBy;if(void 0!==i&&!e.includes(i))throw invalid(`sortBy must be one of: ${e.join(", ")}`,"INVALID_SORT_FIELD");return{page:s,limit:r,offset:(s-1)*r,search:o,sortBy:i,desc:"desc"===t.query.sortOrder}}sorted(t,e,s){if(!e)return t;const r=s?-1:1;return t.sort((t,s)=>text(t[e]).localeCompare(text(s[e]))*r)}groupOf(t,e){const s=read(e,this.cn)||"",r=dnKey(this.userBaseOf(t)),a=values(e.member).filter(t=>!n(t,this.config.group_dummy_user)).map(t=>{const[e]=o(t),s=e.indexOf("=");return dnKey(parentOf(t))===r&&s>0?i(e.slice(s+1).trim()):t});return{id:s,cn:s,displayName:read(e,this.displayName)??s,description:read(e,"description"),color:read(e,this.color),organizationId:t,baseDN:this.groupDn(t,s),members:a,createdAt:read(e,this.createdAt)}}async groups(t,e){try{const{searchEntries:s}=await this.ldap.search({paged:!1,scope:"one",filter:e},this.groupBaseOf(t));return s}catch(t){if(32===r(t))return[];throw t}}async readGroup(t,e){try{const{searchEntries:s}=await this.ldap.search({paged:!1,scope:"base"},this.groupDn(t,e));return s[0]&&this.groupOf(t,s[0])}catch(t){if(32===r(t))return;throw t}}async named(t,e){const[s]=await this.groups(t,`(${this.displayName}=${c(e)})`);return s&&read(s,this.cn)}async onGroup(t){try{return await t()}catch(t){if(32===r(t))throw notFound("group");throw t}}async listRoute(t,e,s){const r=this.page(t,b);let o=`(${this.cn}=*)`;if(r.search){const t=c(r.search);o=`(&${o}(|(${this.displayName}=*${t}*)(description=*${t}*)))`}const i=this.sorted((await this.groups(s,o)).map(t=>this.groupOf(s,t)),r.sortBy,r.desc);e.json({organizationId:s,groups:i.slice(r.offset,r.offset+r.limit),pagination:{page:r.page,limit:r.limit,total:i.length,totalPages:Math.ceil(i.length/r.limit)}})}async createRoute(e,s,r){const o=bodyOf(e);if(!isDisplayName(o.name))throw invalid(N);if(void 0!==o.description&&"string"!=typeof o.description)throw invalid("description must be a string");if(void 0!==o.color&&!isColor(o.color))throw invalid("color must be a hex code (e.g. #RRGGBB or #RGB)");if(await this.named(r,o.name))throw new RouteError(409,"Group already exists","GROUP_EXISTS");const i=t(),a={objectClass:this.config.group_class||["top","groupOfNames"],[this.displayName]:o.name,[this.createdAt]:(new Date).toISOString()};o.description&&(a.description=o.description),o.color&&(a[this.color]=o.color),await this.addGroup(this.groupDn(r,i),[],a,e);const n=await this.readGroup(r,i);s.status(201).json(n)}async updateRoute(t,e,s){const r=bodyOf(t),o=t.params.groupId,i=Object.keys(r).filter(t=>!["name","description","color"].includes(t));if(i.length)throw invalid(`Only "name", "description" and "color" can be updated; unsupported field(s): ${i.join(", ")}`);const{name:a,description:n,color:c}=r;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){const t=await this.named(s,a);if(t&&t!==o)throw new RouteError(409,"Group already exists","GROUP_EXISTS");u[this.displayName]=a}void 0!==n&&(u.description=n||[]),void 0!==c&&(u[this.color]=c||[]),await this.onGroup(()=>this.modifyGroup(this.groupDn(s,o),{replace:u},t)),e.json({success:!0})}async membersRoute(t,e,s){const r=this.page(t,_),o=t.params.groupId,i=await this.readGroup(s,o);if(!i)throw notFound("group");const a=[...new Map(i.members.map(t=>[t.toLowerCase(),t])).values()],n=await this.profiles(s,a);let c=a.map(t=>n.get(t.toLowerCase())??{uid:t});if(r.search){const t=r.search.toLowerCase();c=c.filter(e=>[e.uid,e.displayName||e.cn,e.mail].some(e=>text(e).toLowerCase().includes(t)))}c=this.sorted(c,r.sortBy??"uid",r.desc);const u=Math.ceil(c.length/r.limit);e.json({organizationId:s,id:o,members:c.slice(r.offset,r.offset+r.limit),pagination:{page:r.page,limit:r.limit,total:c.length,totalPages:u,hasNextPage:r.page<u,hasPreviousPage:r.page>1}})}async users(t,e,s){const{deleted:o,deletedValue:i}=this.attrs,a=o?`(!(${o}=${c(i)}))`:"",n=[];for(let o=0;o<e.length;o+=this.maxPage){const i=`(&(|${e.slice(o,o+this.maxPage).map(t=>`(${this.userAttribute}=${c(t)})`).join("")})${a})`;try{const{searchEntries:e}=await this.ldap.search({paged:!1,scope:"one",filter:i,attributes:s},this.userBaseOf(t));n.push(...e)}catch(t){if(32!==r(t))throw t}}return n}async profiles(t,e){const s=[...new Set([...Object.values(this.memberFields),...Object.values($),v])],r=new Map;for(const o of await this.users(t,e,s)){const t=read(o,this.userAttribute);if(!t)continue;const e={};for(const[t,s]of Object.entries(this.memberFields)){const r=read(o,s);void 0!==r&&(e[t]=r)}const s={};for(const[t,e]of Object.entries($)){const r=read(o,e);void 0!==r&&(s[t]=r)}Object.keys(s).length&&(e.name=s),e.isTechnical="TRUE"===read(o,v)?.toUpperCase(),r.set(t.toLowerCase(),e)}return r}async addMembersRoute(t,e,s){const{usernames:r}=bodyOf(t);if(!Array.isArray(r)||0===r.length)throw invalid("usernames must be a non-empty array");if(!r.every(t=>"string"==typeof t&&t.length>0))throw invalid("usernames must be an array of non-empty strings");if(r.length>this.maxPage)throw invalid(`usernames cannot exceed ${this.maxPage} entries per request`);const o=t.params.groupId,i=await this.readGroup(s,o);if(!i)throw notFound("group");const n=[...new Map(r.map(t=>[t.toLowerCase(),t])).keys()],c=new Map((await this.users(s,n,[this.userAttribute])).map(t=>{const e=read(t,this.userAttribute)||"";return[e.toLowerCase(),e]}));if(n.some(t=>!c.has(t)))throw notFound("user");const u=new Set(i.members.map(t=>t.toLowerCase())),p=n.filter(t=>!u.has(t)).map(t=>`${this.userAttribute}=${a(c.get(t))},${this.userBaseOf(s)}`),h=this.groupDn(s,o);p.length&&(await this.onGroup(()=>this.ldap.modify(h,{add:{member:p}},t)),await this.dropPlaceholder(h,t)),e.json({success:!0})}async dropPlaceholder(t,e){const s=this.config.group_dummy_user;if(s)try{await this.ldap.modify(t,{delete:{member:s}},e)}catch(t){if(![16,65].includes(r(t)??0))throw t}}async removeMemberRoute(t,e,s){const o=this.groupDn(s,t.params.groupId),i=`${this.userAttribute}=${a(t.params.userId)},${this.userBaseOf(s)}`;try{await this.ldap.modify(o,{delete:{member:i}},t)}catch(e){const s=r(e);if(32===s)throw notFound("group");if(16===s)throw notFound("member");if(65!==s||!this.config.group_dummy_user)throw e;await this.ldap.modify(o,{replace:{member:[this.config.group_dummy_user]}},t)}e.json({success:!0})}}export{TwakeGroups as default,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 o}from"../../lib/ldapCodes.js";import{parseDn as i,escapeDnValue as a,isDummyMemberDn as n,escapeLdapFilter as c,isAuthzRefusal as u,unescapeDnValue as p,normalizeDn as h}from"../../lib/utils.js";import d 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{valueOf as w,lifecycleAttributes as y,first as b}from"./lifecycleAttributes.js";const read=(t,e)=>b(w(t,e)),_="{org}",O=["displayName","description","createdAt"],v=["uid","displayName","mail","jobTitle"],$={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"},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),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 j=/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/,isColor=t=>"string"==typeof t&&j.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 h(t)}catch{return t.toLowerCase()}}function parentOf(t){return i(t).slice(1).join(",")}function rdnValue(t){const[e]=i(t),r=e.indexOf("=");return r>0?p(e.slice(r+1).trim()):t}function branchPattern(t){const[e,r]=i(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 k=Symbol("whole member lists");function organizationIn(t,e){const r=t.exec(parentOf(i(e).join(",")));return r?.groups?p(r.groups.org):void 0}class TwakeGroups extends d{constructor(r){super(r),this.name="twakeGroups",this.hooks={ldapaddrequest:([t,e,r])=>(this.checkMembers(t,e.member),void 0!==this.organizationOf(t)&&void 0!==w(e,this.displayName)&&void 0===w(e,this.createdAt)&&(e={...e,[this.createdAt]:(new Date).toISOString()}),[t,e,r]),ldapmodifyrequest:([t,e,r,s])=>(this.checkMembers(t,e.add?.member),this.checkMembers(t,e.replace?.member),[t,e,r,s]),ldaprenamerequest:async([t,r,s])=>{const i=movedInto(this.groupPattern,t,r);if(void 0===i)return[t,r,s];let a;try{const{searchEntries:e}=await this.ldap.search({paged:!1,scope:"base",attributes:["member"],[k]:!0},t);a=e[0]?.member}catch(t){if(32!==o(t))throw t}if(void 0!==this.foreign(i,a))throw new e(`The group holds members of another organization than ${i}`);return[t,r,s]},ldapsearchfilter:async([t,e,r])=>[r?.[k]?t:await this.hideTombstones(t),e,r],scimgroupcreate:([e,r,s])=>(s??(s=new m(this.config).groupBase(r)),[this.groupPattern.test(i(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(_))throw new Error(`${this.name}: ${t} must hold ${_}`);this.groupPattern=branchPattern(this.groupBase),this.attrs=y(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={...$,...this.config.twake_group_member_fields}}adaptSchema(t){const e={[this.displayName]:{type:"string",required:!0},[this.color]:{type:"string",test:j.source},[this.createdAt]:{type:"string"}};for(const[r,s]of Object.entries(e)){const e=Object.keys(t.attributes).find(t=>t.toLowerCase()===r.toLowerCase()),o=e?t.attributes[e]:s;e&&delete t.attributes[e],t.attributes[r]=o}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`)}organizationOf(t){return organizationIn(this.groupPattern,t)}groupBaseOf(t){return this.groupBase.replace(_,a(t))}userBaseOf(t){return this.userBase.replace(_,a(t))}checkMembers(t,r){const s=this.organizationOf(t);if(!s)return;const o=this.foreign(s,r);if(void 0!==o)throw new e(`${o} 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.search({paged:!1,scope:"one",filter:`(${e}=${c(r)})`,attributes:["dn"]},this.userBaseOf(t));return s.map(t=>t.dn)}catch(t){if(32===o(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);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,o=this.name){return async(i,a)=>{try{const t=i.params.id;await this.checkOrganization(t),await e(i,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:o,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){if(!this.orgPattern)return;const e=this.config.twake_group_organization_status_attribute||"twakeOrgStatus";let r;try{r=(await this.ldap.search({paged:!1,scope:"base",attributes:[e]},this.orgPattern.replace(_,a(t)))).searchEntries[0]}catch(t){if(32!==o(t))throw t}if(!r)throw notFound("organization");const s=this.config.twake_group_organization_deleted_value||"deleted";if(read(r,e)===s)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)),o=t.query.search||void 0;if(void 0!==o&&("string"!=typeof o||o.length<2))throw invalid("Search query must be at least 2 characters","INVALID_SEARCH_QUERY");const i=t.query.sortBy;if(void 0!==i&&!e.includes(i))throw invalid(`sortBy must be one of: ${e.join(", ")}`,"INVALID_SORT_FIELD");return{page:r,limit:s,offset:(r-1)*s,search:o,sortBy:i,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)),o=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:o,createdAt:read(e,this.createdAt)}}async groups(t,e,r,s){try{const{searchEntries:o}=await this.ldap.search({paged:!1,scope:"one",filter:e,...r&&{attributes:r}},this.groupBaseOf(t),s);return o}catch(t){if(32===o(t))return[];throw t}}async readGroup(t,e){try{const{searchEntries:r}=await this.ldap.search({paged:!1,scope:"base"},this.groupDn(t,e));return r[0]&&this.groupOf(t,r[0])}catch(t){if(32===o(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===o(t))throw notFound("group");throw t}}async listRoute(t,e,r){const s=this.page(t,O);let o=`(${this.cn}=*)`;if(s.search){const t=c(s.search);o=`(&${o}(|(${this.displayName}=*${t}*)(description=*${t}*)))`}const i=this.sorted((await this.groups(r,o)).map(t=>this.groupOf(r,t)),s.sortBy,s.desc);e.json({organizationId:r,groups:i.slice(s.offset,s.offset+s.limit),pagination:{page:s.page,limit:s.limit,total:i.length,totalPages:Math.ceil(i.length/s.limit)}})}async createRoute(e,r,s){const o=bodyOf(e);if(!isDisplayName(o.name))throw invalid(N);if(void 0!==o.description&&"string"!=typeof o.description)throw invalid("description must be a string");if(void 0!==o.color&&!isColor(o.color))throw invalid("color must be a hex code (e.g. #RRGGBB or #RGB)");if(await this.named(s,o.name))throw new RouteError(409,"Group already exists","GROUP_EXISTS");const i=t(),a={objectClass:this.config.group_class||["top","groupOfNames"],[this.displayName]:o.name,[this.createdAt]:(new Date).toISOString()};o.description&&(a.description=o.description),o.color&&(a[this.color]=o.color),await this.addGroup(this.groupDn(s,i),[],a,e);const n=await this.readGroup(s,i);r.status(201).json(n)}async updateRoute(t,e,r){const s=bodyOf(t),o=t.params.groupId,i=Object.keys(s).filter(t=>!["name","description","color"].includes(t));if(i.length)throw invalid(`Only "name", "description" and "color" can be updated; unsupported field(s): ${i.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){const t=await this.named(r,a);if(t&&t!==o)throw new RouteError(409,"Group already exists","GROUP_EXISTS");u[this.displayName]=a}void 0!==n&&(u.description=n||[]),void 0!==c&&(u[this.color]=c||[]),await this.onGroup(()=>this.modifyGroup(this.groupDn(r,o),{replace:u},t)),e.json({success:!0})}async membersRoute(t,e,r){const s=this.page(t,v),o=t.params.groupId,i=await this.readGroup(r,o);if(!i)throw notFound("group");e.json({organizationId:r,id:o,...await this.memberPage(s,r,i.members)})}async memberPage(t,e,r,s=()=>({}),o){const i=[...new Map(r.map(t=>[t.toLowerCase(),t])).values()],a=await this.profiles(e,i,o);let n=i.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:i,deletedValue:a}=this.attrs,n=i?`(!(${i}=${c(a)}))`:"",u=[];for(let i=0;i<e.length;i+=this.maxPage){const a=`(&(|${e.slice(i,i+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);u.push(...e)}catch(t){if(32!==o(t))throw t}}return u}async profiles(t,e,r){const s=[...new Set([...Object.values(this.memberFields),...Object.values(I),R])],o=new Map;for(const i of await this.users(t,e,s,r)){const t=read(i,this.userAttribute);if(!t)continue;const e={};for(const[t,r]of Object.entries(this.memberFields)){const s=read(i,r);void 0!==s&&(e[t]=s)}const r={};for(const[t,e]of Object.entries(I)){const s=read(i,e);void 0!==s&&(r[t]=s)}Object.keys(r).length&&(e.name=r),e.isTechnical="TRUE"===read(i,R)?.toUpperCase(),o.set(t.toLowerCase(),e)}return o}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 o=t.params.groupId,i=await this.readGroup(r,o);if(!i)throw notFound("group");const a=[...new Map(s.map(t=>[t.toLowerCase(),t])).keys()],n=new Map((await this.users(r,a,[this.userAttribute])).map(t=>{const e=read(t,this.userAttribute)||"";return[e.toLowerCase(),e]}));if(a.some(t=>!n.has(t)))throw notFound("user");const c=new Set(i.members.map(t=>t.toLowerCase())),u=a.filter(t=>!c.has(t)).map(t=>this.userDn(r,n.get(t))),p=this.groupDn(r,o);u.length&&(await this.onGroup(()=>this.ldap.modify(p,{add:{member:u}},t)),await this.dropPlaceholder(p,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(o(t)??0))throw t}}async removeMemberRoute(t,e,r){const s=this.groupDn(r,t.params.groupId),i=this.userDn(r,t.params.userId);try{await this.ldap.modify(s,{delete:{member:i}},t)}catch(e){const r=o(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,_ as ORG,RouteError,bodyOf,branchPattern,TwakeGroups as default,dnKey,invalid,isDisplayName,movedInto,notFound,organizationIn,parentOf,rdnValue,read,values};
@@ -1 +1 @@
1
- import t from"../../abstract/plugin.js";import{NotFoundError as i,BadRequestError as e}from"../../lib/errors.js";import{jsonBody as a}from"../../lib/expressFormatedResponses.js";import{CozyStackProvider as n,ClouderyProvider as r}from"../../lib/instanceProviders.js";import{extractLdapCode as s}from"../../lib/ldapCodes.js";import{rdnValue as o,asyncHandler as c,escapeRegex as h,unescapeDnValue as d,escapeDnValue as l,escapeLdapFilter as u}from"../../lib/utils.js";import{lifecycleAttributes as g,isTombstone as w}from"./lifecycleAttributes.js";const first=t=>{const i=Array.isArray(t)?t[0]:t;return(null==i?"":i.toString())||void 0},f=/(^|\/)twake\/(cozy|cloudery)Provision(\.js)?$/;class TwakeInstances extends t{constructor(t){super(t),this.name="twakeInstances",this.roles=["consistency","api"],this.dependencies={rabbitmq:"core/rabbitmq"},this.offers=[],this.queues=new Map,this.hooks={ldapaddrequest:([t,i,e])=>{if(this.patterns.some(i=>i.test(t))||this.isOrganization(t)){const t=[this.fqdnAttribute,this.sentAttribute,this.organizationFqdn].map(t=>t.toLowerCase());for(const e of Object.keys(i))t.includes(e.toLowerCase())&&delete i[e]}return[t,i,e]},ldapaddafter:async([t,i])=>{const e=this.organizationOf(t);e&&await this.serialized(`organization:${e}`,async()=>{try{await this.giveOrganizationAddress(e)}catch(i){this.logger.error({plugin:this.name,event:"organization account",dn:t,error:String(i)})}});const a=this.account(t,i);a&&await this.serialized(t,async()=>{try{await this.provide(t,i,a)}catch(i){this.logger.error({plugin:this.name,event:"create",dn:t,error:String(i)})}})},ldapdeletedone:async t=>{const i=this.provider;if(i instanceof n)for(const e of Array.isArray(t)?t:[t]){const t=this.match(e),a=/^uid=/i.test(e)?o(e):"";t&&a&&await this.serialized(e,async()=>{try{await i.destroy(this.id(a,t),a)}catch(t){this.logger.error({plugin:this.name,event:"destroy",dn:e,error:String(t)})}})}}};const i=this.config,e=(i.plugin||[]).find(t=>f.test(t));if(e)throw new Error(`${this.name}: ${e} gives accounts their instance too; load one of them`);this.patterns=(i.twake_instance_dn||[]).map(t=>new RegExp(t,"i"));const a=new Set(["uid"]);for(const t of this.patterns)for(const[,i]of t.source.matchAll(/\(\?<(\w+)>/g))a.add(i);for(const[,t]of(i.twake_instance_id||"{uid}").matchAll(/\{(\w+)\}/g))if(!a.has(t))throw new Error(`${this.name}: --twake-instance-id names {${t}}, which no --twake-instance-dn group captures`);this.lifecycle=g(i),this.fqdnAttribute=i.twake_instance_fqdn_attribute||"twakeWorkspaceUrl",this.sentAttribute=i.twake_instance_sent_attribute||"twakeCreatedEventAt";const s=i.twake_instance_timeout||3e4;if("cozy-stack"===i.twake_instance_provider){if(!i.twake_instance_cozy_url||!i.twake_instance_cozy_domain)throw new Error(`${this.name}: --twake-instance-cozy-url and --twake-instance-cozy-domain are required`);this.provider=new n(i.twake_instance_cozy_url.replace(/\/$/,""),i.twake_instance_cozy_user||"admin",i.twake_instance_cozy_passphrase||"",i.twake_instance_cozy_domain,i.twake_instance_cozy_context||"",i.twake_instance_cozy_apps||"",s),this.locale=i.twake_instance_locale||"fr"}else{if("cloudery"!==i.twake_instance_provider)throw new Error(`${this.name}: --twake-instance-provider must be cloudery or cozy-stack`);{if(!i.twake_instance_cloudery_url||!i.twake_instance_cloudery_domain)throw new Error(`${this.name}: --twake-instance-cloudery-url and --twake-instance-cloudery-domain are required`);const t=(i.twake_instance_cloudery_offer||"").split(";").map(t=>t.trim());if(t.length>1&&""===t[t.length-1]&&t.pop(),t.length>1&&t.length>this.patterns.length)throw new Error(`${this.name}: --twake-instance-cloudery-offer has more offers than --twake-instance-dn has expressions`);t.length>1&&(this.offers=t),this.provider=new r(i.twake_instance_cloudery_url.replace(/\/$/,""),i.twake_instance_cloudery_token||"",i.twake_instance_cloudery_domain,t.length>1?"":t[0],s),this.locale=i.twake_instance_locale||"en"}}}async api(t){const n=this.requirePlugin("rabbitmq");if(!n||!await n.getRawClient())throw new Error(`${this.name}: RabbitMQ is required and unreachable`);const s=await this.server.ldap.schemaIndex(),o=[this.fqdnAttribute,this.sentAttribute,...this.config.twake_instance_organization_base?[this.organizationDomain,this.organizationFqdn,this.organizationName]:[]].filter(t=>t&&!s.getAttributeType(t));if(o.length)throw new Error(`${this.name}: the directory schema defines no ${o.join(", ")}; load a schema that does, or name other attributes`);if(this.organizationFqdn&&this.organizationFqdn.toLowerCase()===this.organizationName.toLowerCase())throw new Error(`${this.name}: an organization's name and address cannot share the attribute ${this.organizationName}`);t.post(`${this.config.api_prefix}/v1/twake/instances/ensure`,c(async(t,n)=>{const r=a(t,n);if(!1===r)return;let s=r?.dn;if(void 0===s&&"string"==typeof r?.mail&&r.mail){const e=await this.findByMail(r.mail,t);if(!e)throw new i(`No account for ${r.mail}`);s=e.dn}if("string"!=typeof s||!s)throw new e("Give the account as dn or mail");n.json({state:await this.ensureInstance(s,t)})})),this.provider instanceof r&&await n.subscribe(this.config.twake_instance_auth_exchange||"auth",this.config.twake_instance_workplace_created_key||"workplace.created",this.config.twake_instance_queue||"workplace.created.ldap-rest",t=>this.onWorkplaceCreated(t))}async ensureInstance(t,a){return this.serialized(t,async()=>{const n=await this.read(t,a);if(!n)throw new i(`No entry at ${t}`);const r=this.account(t,n);if(!r)throw new e(`${t} gets no instance`);if(first(this.value(n,this.sentAttribute)))return"ready";a&&await this.server.ldap.modify(t,{replace:{[this.sentAttribute]:[]}},a);const s=first(this.value(n,this.fqdnAttribute));if(s){const i=s.toLowerCase()===this.provider.address(r)?{fqdn:s,ready:!0}:await this.provider.find(r,s);if(i?.ready)return await this.ready(t,n,r,i.fqdn,a),"ready"}return this.provide(t,n,r,a)})}async ensureOrganization(t){return this.serialized(`organization:${t.id}`,async()=>{const e=await this.findOrganization(t.id);if(!e)throw new i(`No organization ${t.id}`);t.name&&!first(this.value(e,this.organizationName))&&(await this.server.ldap.modify(e.dn,{replace:{[this.organizationName]:t.name}}),e[this.organizationName]=t.name);const a=this.organizationFqdn&&first(this.value(e,this.organizationFqdn)),n=this.organizationRequest(t),s=a?{fqdn:a,ready:!0}:await this.provider.find(n);if(first(this.value(e,this.sentAttribute)))return s?.ready?await this.organizationReady(e,s.fqdn):await this.releaseMembers(e.dn),"ready";let o=s?.ready&&s.fqdn;return s||(this.provider instanceof r&&await this.provider.createOrganization(t),o=await this.provider.create(n)),o?(await this.organizationReady(e,o),"ready"):"pending"})}organizationRequest(t){return{id:t.id,email:this.organizationMail(t.id,t.domain),publicName:t.name,locale:this.locale,orgId:t.id,orgDomain:t.domain,offer:this.config.twake_instance_cloudery_organization_offer}}async requestOrganization(t){if(!(this.provider instanceof r))return;const i=await this.findOrganization(t);if(!i||first(this.value(i,this.sentAttribute)))return;const e=first(this.value(i,this.organizationName)),a=first(this.value(i,this.organizationDomain));if(!e||!a)return void this.logger.warn(`${this.name}: organization ${t} has no ${this.organizationName} or ${this.organizationDomain}, so its instance cannot be asked for`);const n=this.organizationRequest({id:t,name:e,domain:a});if(!await this.provider.find(n)){await this.provider.createOrganization({id:t,name:e,domain:a});try{await this.provider.create(n)}catch(t){if(!await this.provider.find(n))throw t}}}async onWorkplaceCreated(t){const{internalEmail:i,workplaceFqdn:e}=t;if(!i||!e)return;const a=t.twakeId&&await this.findOrganization(t.twakeId);if(a){const n=t.twakeId.toLowerCase(),r=first(this.value(a,this.organizationDomain)),s=i.toLowerCase();if(r?this.organizationMail(n,r)===s:s.startsWith(`${n}@`))return this.serialized(`organization:${t.twakeId}`,async()=>{const t=await this.read(a.dn);t&&await this.organizationReady(t,e)})}const n=await this.findByMail(i);n?await this.settle(n.dn,e):this.logger.debug(`${this.name}: no account here for ${i}`)}async settle(t,i){await this.serialized(t,async()=>{const e=await this.read(t),a=e&&this.account(t,e);e&&a&&(first(this.value(e,this.sentAttribute))||await this.ready(t,e,a,i))})}async provide(t,i,e,a){e.orgId&&await this.requestOrganization(e.orgId);const n=await this.provider.find(e),r=n?n.ready&&n.fqdn:await this.provider.create(e);return r?(await this.ready(t,i,e,r,a),"ready"):"pending"}async ready(t,i,e,a,n){if(first(this.value(i,this.fqdnAttribute))!==a&&await this.server.ldap.modify(t,{replace:{[this.fqdnAttribute]:a}},n),first(this.value(i,this.sentAttribute)))return;const s=this.provider instanceof r&&e.orgId&&await this.findOrganization(e.orgId);if(s&&!first(this.value(s,this.sentAttribute))){if(t.toLowerCase().endsWith(`,${s.dn.toLowerCase()}`))return void this.logger.info(`${this.name}: ${t} waits for organization ${e.orgId}`);this.logger.warn(`${this.name}: ${t} is not below its organization entry, so it is announced before organization ${e.orgId} is linked; clear its ${this.sentAttribute} and send it again once the organization is announced`)}await this.announce(this.config.twake_instance_auth_exchange||"auth",this.config.twake_instance_user_created_key||"user.created",{twakeId:e.twakeId,internalEmail:e.email,workplaceFqdn:a,...e.orgId?{organizationId:e.orgId}:{},...e.orgDomain?{domain:e.orgDomain,organizationDomain:e.orgDomain}:{},...e.phone?{mobile:e.phone}:{}}),await this.server.ldap.modify(t,{replace:{[this.sentAttribute]:(new Date).toISOString()}},n)}async organizationReady(t,i){const e=t.dn,a=first(this.value(t,this.organizationId));if(a&&this.provider instanceof r&&await this.provider.linkOrganization(a,i),a&&await this.fillOrganizationAccount(a,i).catch(t=>this.logger.error({plugin:this.name,event:"organization account",organization:a,error:String(t)})),first(this.value(t,this.sentAttribute)))return this.releaseMembers(e);this.organizationFqdn&&!first(this.value(t,this.organizationFqdn))&&await this.server.ldap.modify(e,{replace:{[this.organizationFqdn]:i}});const n=first(this.value(t,this.organizationDomain)),s=first(this.value(t,this.organizationName));await this.announce(this.config.twake_instance_b2b_exchange||"b2b",this.config.twake_instance_organization_created_key||"organization.created",{organizationId:a,workplaceFqdn:i,...s?{organization:s}:{},...n?{domain:n}:{}}),await this.server.ldap.modify(e,{replace:{[this.sentAttribute]:(new Date).toISOString()}}),await this.releaseMembers(e)}organizationOf(t){const i=this.config.twake_instance_organization_account;if(!i)return;const e=h(i).replace("\\{id\\}","(?<id>(?:[^,\\\\]|\\\\.)+)").replace(/\\\{id\\\}/g,"\\k<id>"),a=new RegExp(`^${e}$`,"i").exec(t)?.groups?.id;return a&&d(a)}async giveOrganizationAddress(t){const i=await this.findOrganization(t);if(!i||!first(this.value(i,this.sentAttribute)))return;let e=this.organizationFqdn&&first(this.value(i,this.organizationFqdn));if(!e){const a=await this.provider.find(this.organizationRequest({id:t,name:first(this.value(i,this.organizationName))||"",domain:first(this.value(i,this.organizationDomain))||""}));a?.ready&&(e=a.fqdn)}e&&await this.fillOrganizationAccount(t,e)}async fillOrganizationAccount(t,i){const e=this.config.twake_instance_organization_account;if(!e)return;const a=e.replace(/\{id\}/g,()=>l(t)),n=await this.read(a);n&&!first(this.value(n,this.fqdnAttribute))&&await this.server.ldap.modify(a,{replace:{[this.fqdnAttribute]:i}})}async releaseMembers(t){const i=await this.server.ldap.search({paged:!1,scope:"sub",filter:`(&(${this.fqdnAttribute}=*)(!(${this.sentAttribute}=*)))`},t);let e=0;for(const t of i.searchEntries)try{await this.release(t)}catch(i){e++,this.logger.error(`${this.name}: ${t.dn} not released: ${String(i)}`)}if(e)throw new Error(`${this.name}: ${e} member(s) of ${t} not released`)}async release(t){const i=t.dn,e=this.account(i,t),a=first(this.value(t,this.fqdnAttribute));if(!e||!a)return;const n=a.toLowerCase()===this.provider.address(e)?{fqdn:a,ready:!0}:await this.provider.find(e,a);if(n?.ready)return this.settle(i,n.fqdn);this.logger.warn(`${this.name}: ${i} stays held, its address ${a} is not confirmed; ensureInstance sends it once it is`)}async serialized(t,i){const e=t.toLowerCase(),a=(this.queues.get(e)??Promise.resolve()).catch(()=>{}).then(i),n=a.catch(()=>{});return this.queues.set(e,n),n.then(()=>{this.queues.get(e)===n&&this.queues.delete(e)}),a}get organizationId(){return this.config.twake_instance_organization_id_attribute||"ou"}get organizationDomain(){return this.config.twake_instance_organization_domain_attribute||"twakeDomain"}get organizationName(){return this.config.twake_instance_organization_name_attribute||"description"}get organizationFqdn(){return this.config.twake_instance_organization_fqdn_attribute||""}organizationMail(t,i){return`${t}@${i}`.toLowerCase()}isOrganization(t){const i=this.config.twake_instance_organization_base;if(!i)return!1;return t.slice(t.indexOf(",")+1).toLowerCase()===i.toLowerCase()}async findOrganization(t){const i=this.config.twake_instance_organization_base;if(!i)return;return(await this.server.ldap.search({paged:!1,scope:"one",filter:`(${this.organizationId}=${u(t)})`},i)).searchEntries[0]}async read(t,i){try{return(await this.server.ldap.search({paged:!1,scope:"base"},t,i)).searchEntries[0]}catch(i){const a=s(i);if(32===a)return;if(34===a)throw new e(`Invalid DN: ${t}`);throw i}}async announce(t,i,e){const a=this.requirePlugin("rabbitmq");if(!a||!await a.getRawClient())throw new Error(`${this.name}: RabbitMQ unreachable, ${i} not sent`);await a.publish(t,i,e),this.logger.info({plugin:this.name,event:i,exchange:t})}account(t,i){const e=this.match(t);if(!e)return;if(w(i,this.lifecycle))return;const a=this.config.twake_instance_skip_attribute;if(a&&first(this.value(i,a))?.toLowerCase()===(this.config.twake_instance_skip_value||"").toLowerCase())return;const r=first(this.value(i,"mail")),s=first(this.value(i,"uid"));if(!r||!s)return void this.logger.warn(`${this.name}: ${t} has no mail or uid, no instance`);const o=this.provider instanceof n,c=e.groups?.org||(o?this.config.twake_instance_cozy_org_id:"")||void 0,h=e.groups?.org?r.split("@")[1]:o&&this.config.twake_instance_cozy_org_domain||void 0,d=this.offers[this.patterns.findIndex(i=>i.test(t))];return{id:this.id(s,e),...o?{oidc:s}:{},twakeId:s,email:r,publicName:first(this.value(i,"displayName"))||first(this.value(i,"cn"))||s,locale:first(this.value(i,"preferredLanguage"))||this.locale,phone:first(this.value(i,"mobile")),...c?{orgId:c}:{},...h?{orgDomain:h}:{},...d?{offer:d}:{}}}match(t){return this.patterns.map(i=>i.exec(t)).find(t=>null!==t)}id(t,i){const e={uid:t,...i.groups};return(this.config.twake_instance_id||"{uid}").replace(/\{(\w+)\}/g,(t,i)=>e[i]??"").replace(/\./g,"").toLowerCase()}value(t,i){const e=i.toLowerCase(),a=Object.keys(t).find(t=>t.toLowerCase()===e);return a?t[a]:void 0}async findByMail(t,i){return(await this.server.ldap.search({paged:!1,scope:"sub",filter:`(mail=${u(t)})`},this.config.ldap_base,i)).searchEntries.find(t=>this.patterns.some(i=>i.test(t.dn)))}}export{TwakeInstances as default};
1
+ import t from"../../abstract/plugin.js";import{NotFoundError as e,BadRequestError as i}from"../../lib/errors.js";import{jsonBody as a}from"../../lib/expressFormatedResponses.js";import{CozyStackProvider as n,ClouderyProvider as r}from"../../lib/instanceProviders.js";import{extractLdapCode as s}from"../../lib/ldapCodes.js";import{rdnValue as o,asyncHandler as c,escapeLdapFilter as h}from"../../lib/utils.js";import{lifecycleAttributes as d,isTombstone as l}from"./lifecycleAttributes.js";const first=t=>{const e=Array.isArray(t)?t[0]:t;return(null==e?"":e.toString())||void 0},u=/(^|\/)twake\/(cozy|cloudery)Provision(\.js)?$/;class TwakeInstances extends t{constructor(t){super(t),this.name="twakeInstances",this.roles=["consistency","api"],this.dependencies={rabbitmq:"core/rabbitmq"},this.offers=[],this.queues=new Map,this.hooks={ldapaddrequest:([t,e,i])=>{if(this.patterns.some(e=>e.test(t))||this.isOrganization(t)){const t=[this.fqdnAttribute,this.sentAttribute,this.organizationFqdn].map(t=>t.toLowerCase());for(const i of Object.keys(e))t.includes(i.toLowerCase())&&delete e[i]}return[t,e,i]},ldapaddafter:async([t,e])=>{const i=this.account(t,e);i&&await this.serialized(t,async()=>{try{await this.provide(t,e,i)}catch(e){this.logger.error({plugin:this.name,event:"create",dn:t,error:String(e)})}})},ldapdeletedone:async t=>{const e=this.provider;if(e instanceof n)for(const i of Array.isArray(t)?t:[t]){const t=this.match(i),a=/^uid=/i.test(i)?o(i):"";t&&a&&await this.serialized(i,async()=>{try{await e.destroy(this.id(a,t),a)}catch(t){this.logger.error({plugin:this.name,event:"destroy",dn:i,error:String(t)})}})}}};const e=this.config,i=(e.plugin||[]).find(t=>u.test(t));if(i)throw new Error(`${this.name}: ${i} gives accounts their instance too; load one of them`);this.patterns=(e.twake_instance_dn||[]).map(t=>new RegExp(t,"i"));const a=new Set(["uid"]);for(const t of this.patterns)for(const[,e]of t.source.matchAll(/\(\?<(\w+)>/g))a.add(e);for(const[,t]of(e.twake_instance_id||"{uid}").matchAll(/\{(\w+)\}/g))if(!a.has(t))throw new Error(`${this.name}: --twake-instance-id names {${t}}, which no --twake-instance-dn group captures`);this.lifecycle=d(e),this.fqdnAttribute=e.twake_instance_fqdn_attribute||"twakeWorkspaceUrl",this.sentAttribute=e.twake_instance_sent_attribute||"twakeCreatedEventAt";const s=e.twake_instance_timeout||3e4;if("cozy-stack"===e.twake_instance_provider){if(!e.twake_instance_cozy_url||!e.twake_instance_cozy_domain)throw new Error(`${this.name}: --twake-instance-cozy-url and --twake-instance-cozy-domain are required`);this.provider=new n(e.twake_instance_cozy_url.replace(/\/$/,""),e.twake_instance_cozy_user||"admin",e.twake_instance_cozy_passphrase||"",e.twake_instance_cozy_domain,e.twake_instance_cozy_context||"",e.twake_instance_cozy_apps||"",s),this.locale=e.twake_instance_locale||"fr"}else{if("cloudery"!==e.twake_instance_provider)throw new Error(`${this.name}: --twake-instance-provider must be cloudery or cozy-stack`);{if(!e.twake_instance_cloudery_url||!e.twake_instance_cloudery_domain)throw new Error(`${this.name}: --twake-instance-cloudery-url and --twake-instance-cloudery-domain are required`);const t=(e.twake_instance_cloudery_offer||"").split(";").map(t=>t.trim());if(t.length>1&&""===t[t.length-1]&&t.pop(),t.length>1&&t.length>this.patterns.length)throw new Error(`${this.name}: --twake-instance-cloudery-offer has more offers than --twake-instance-dn has expressions`);t.length>1&&(this.offers=t),this.provider=new r(e.twake_instance_cloudery_url.replace(/\/$/,""),e.twake_instance_cloudery_token||"",e.twake_instance_cloudery_domain,t.length>1?"":t[0],s),this.locale=e.twake_instance_locale||"en"}}}async api(t){const n=this.requirePlugin("rabbitmq");if(!n||!await n.getRawClient())throw new Error(`${this.name}: RabbitMQ is required and unreachable`);const s=await this.server.ldap.schemaIndex(),o=[this.fqdnAttribute,this.sentAttribute,...this.config.twake_instance_organization_base?[this.organizationDomain,this.organizationFqdn,this.organizationName]:[]].filter(t=>t&&!s.getAttributeType(t));if(o.length)throw new Error(`${this.name}: the directory schema defines no ${o.join(", ")}; load a schema that does, or name other attributes`);if(this.organizationFqdn&&this.organizationFqdn.toLowerCase()===this.organizationName.toLowerCase())throw new Error(`${this.name}: an organization's name and address cannot share the attribute ${this.organizationName}`);t.post(`${this.config.api_prefix}/v1/twake/instances/ensure`,c(async(t,n)=>{const r=a(t,n);if(!1===r)return;let s=r?.dn;if(void 0===s&&"string"==typeof r?.mail&&r.mail){const i=await this.findByMail(r.mail,t);if(!i)throw new e(`No account for ${r.mail}`);s=i.dn}if("string"!=typeof s||!s)throw new i("Give the account as dn or mail");n.json({state:await this.ensureInstance(s,t)})})),this.provider instanceof r&&await n.subscribe(this.config.twake_instance_auth_exchange||"auth",this.config.twake_instance_workplace_created_key||"workplace.created",this.config.twake_instance_queue||"workplace.created.ldap-rest",t=>this.onWorkplaceCreated(t))}async ensureInstance(t,a){return this.serialized(t,async()=>{const n=await this.read(t,a);if(!n)throw new e(`No entry at ${t}`);const r=this.account(t,n);if(!r)throw new i(`${t} gets no instance`);if(first(this.value(n,this.sentAttribute)))return"ready";a&&await this.server.ldap.modify(t,{replace:{[this.sentAttribute]:[]}},a);const s=first(this.value(n,this.fqdnAttribute));if(s){const e=s.toLowerCase()===this.provider.address(r)?{fqdn:s,ready:!0}:await this.provider.find(r,s);if(e?.ready)return await this.ready(t,n,r,e.fqdn,a),"ready"}return this.provide(t,n,r,a)})}async ensureOrganization(t){return this.serialized(`organization:${t.id}`,async()=>{const i=await this.findOrganization(t.id);if(!i)throw new e(`No organization ${t.id}`);t.name&&!first(this.value(i,this.organizationName))&&(await this.server.ldap.modify(i.dn,{replace:{[this.organizationName]:t.name}}),i[this.organizationName]=t.name);const a=this.organizationFqdn&&first(this.value(i,this.organizationFqdn)),n=this.organizationRequest(t),s=a?{fqdn:a,ready:!0}:await this.provider.find(n);if(first(this.value(i,this.sentAttribute)))return s?.ready?await this.organizationReady(i,s.fqdn):await this.releaseMembers(i.dn),"ready";let o=s?.ready&&s.fqdn;return s||(this.provider instanceof r&&await this.provider.createOrganization(t),o=await this.provider.create(n)),o?(await this.organizationReady(i,o),"ready"):"pending"})}organizationRequest(t){return{id:t.id,email:this.organizationMail(t.id,t.domain),publicName:t.name,locale:this.locale,orgId:t.id,orgDomain:t.domain,offer:this.config.twake_instance_cloudery_organization_offer}}async requestOrganization(t){if(!(this.provider instanceof r))return;const e=await this.findOrganization(t);if(!e||first(this.value(e,this.sentAttribute)))return;const i=first(this.value(e,this.organizationName)),a=first(this.value(e,this.organizationDomain));if(!i||!a)return void this.logger.warn(`${this.name}: organization ${t} has no ${this.organizationName} or ${this.organizationDomain}, so its instance cannot be asked for`);const n=this.organizationRequest({id:t,name:i,domain:a});if(!await this.provider.find(n)){await this.provider.createOrganization({id:t,name:i,domain:a});try{await this.provider.create(n)}catch(t){if(!await this.provider.find(n))throw t}}}async onWorkplaceCreated(t){const{internalEmail:e,workplaceFqdn:i}=t;if(!e||!i)return;const a=t.twakeId&&await this.findOrganization(t.twakeId);if(a){const n=t.twakeId.toLowerCase(),r=first(this.value(a,this.organizationDomain)),s=e.toLowerCase();if(r?this.organizationMail(n,r)===s:s.startsWith(`${n}@`))return this.serialized(`organization:${t.twakeId}`,async()=>{const t=await this.read(a.dn);t&&await this.organizationReady(t,i)})}const n=await this.findByMail(e);n?await this.settle(n.dn,i):this.logger.debug(`${this.name}: no account here for ${e}`)}async settle(t,e){await this.serialized(t,async()=>{const i=await this.read(t),a=i&&this.account(t,i);i&&a&&(first(this.value(i,this.sentAttribute))||await this.ready(t,i,a,e))})}async provide(t,e,i,a){i.orgId&&await this.requestOrganization(i.orgId);const n=await this.provider.find(i),r=n?n.ready&&n.fqdn:await this.provider.create(i);return r?(await this.ready(t,e,i,r,a),"ready"):"pending"}async ready(t,e,i,a,n){if(first(this.value(e,this.fqdnAttribute))!==a&&await this.server.ldap.modify(t,{replace:{[this.fqdnAttribute]:a}},n),first(this.value(e,this.sentAttribute)))return;const s=this.provider instanceof r&&i.orgId&&await this.findOrganization(i.orgId);if(s&&!first(this.value(s,this.sentAttribute))){if(t.toLowerCase().endsWith(`,${s.dn.toLowerCase()}`))return void this.logger.info(`${this.name}: ${t} waits for organization ${i.orgId}`);this.logger.warn(`${this.name}: ${t} is not below its organization entry, so it is announced before organization ${i.orgId} is linked; clear its ${this.sentAttribute} and send it again once the organization is announced`)}await this.announce(this.config.twake_instance_auth_exchange||"auth",this.config.twake_instance_user_created_key||"user.created",{twakeId:i.twakeId,internalEmail:i.email,workplaceFqdn:a,...i.orgId?{organizationId:i.orgId}:{},...i.orgDomain?{domain:i.orgDomain,organizationDomain:i.orgDomain}:{},...i.phone?{mobile:i.phone}:{}}),await this.server.ldap.modify(t,{replace:{[this.sentAttribute]:(new Date).toISOString()}},n)}async organizationReady(t,e){const i=t.dn,a=first(this.value(t,this.organizationId));if(a&&this.provider instanceof r&&await this.provider.linkOrganization(a,e),first(this.value(t,this.sentAttribute)))return this.releaseMembers(i);this.organizationFqdn&&!first(this.value(t,this.organizationFqdn))&&await this.server.ldap.modify(i,{replace:{[this.organizationFqdn]:e}});const n=first(this.value(t,this.organizationDomain)),s=first(this.value(t,this.organizationName));await this.announce(this.config.twake_instance_b2b_exchange||"b2b",this.config.twake_instance_organization_created_key||"organization.created",{organizationId:a,workplaceFqdn:e,...s?{organization:s}:{},...n?{domain:n}:{}}),await this.server.ldap.modify(i,{replace:{[this.sentAttribute]:(new Date).toISOString()}}),await this.releaseMembers(i)}async releaseMembers(t){const e=await this.server.ldap.search({paged:!1,scope:"sub",filter:`(&(${this.fqdnAttribute}=*)(!(${this.sentAttribute}=*)))`},t);let i=0;for(const t of e.searchEntries)try{await this.release(t)}catch(e){i++,this.logger.error(`${this.name}: ${t.dn} not released: ${String(e)}`)}if(i)throw new Error(`${this.name}: ${i} member(s) of ${t} not released`)}async release(t){const e=t.dn,i=this.account(e,t),a=first(this.value(t,this.fqdnAttribute));if(!i||!a)return;const n=a.toLowerCase()===this.provider.address(i)?{fqdn:a,ready:!0}:await this.provider.find(i,a);if(n?.ready)return this.settle(e,n.fqdn);this.logger.warn(`${this.name}: ${e} stays held, its address ${a} is not confirmed; ensureInstance sends it once it is`)}async serialized(t,e){const i=t.toLowerCase(),a=(this.queues.get(i)??Promise.resolve()).catch(()=>{}).then(e),n=a.catch(()=>{});return this.queues.set(i,n),n.then(()=>{this.queues.get(i)===n&&this.queues.delete(i)}),a}get organizationId(){return this.config.twake_instance_organization_id_attribute||"ou"}get organizationDomain(){return this.config.twake_instance_organization_domain_attribute||"twakeDomain"}get organizationName(){return this.config.twake_instance_organization_name_attribute||"description"}get organizationFqdn(){return this.config.twake_instance_organization_fqdn_attribute||""}organizationMail(t,e){return`${t}@${e}`.toLowerCase()}isOrganization(t){const e=this.config.twake_instance_organization_base;if(!e)return!1;return t.slice(t.indexOf(",")+1).toLowerCase()===e.toLowerCase()}async findOrganization(t){const e=this.config.twake_instance_organization_base;if(!e)return;return(await this.server.ldap.search({paged:!1,scope:"one",filter:`(${this.organizationId}=${h(t)})`},e)).searchEntries[0]}async read(t,e){try{return(await this.server.ldap.search({paged:!1,scope:"base"},t,e)).searchEntries[0]}catch(e){const a=s(e);if(32===a)return;if(34===a)throw new i(`Invalid DN: ${t}`);throw e}}async announce(t,e,i){const a=this.requirePlugin("rabbitmq");if(!a||!await a.getRawClient())throw new Error(`${this.name}: RabbitMQ unreachable, ${e} not sent`);await a.publish(t,e,i),this.logger.info({plugin:this.name,event:e,exchange:t})}account(t,e){const i=this.match(t);if(!i)return;if(l(e,this.lifecycle))return;const a=this.config.twake_instance_skip_attribute;if(a&&first(this.value(e,a))?.toLowerCase()===(this.config.twake_instance_skip_value||"").toLowerCase())return;const r=first(this.value(e,"mail")),s=first(this.value(e,"uid"));if(!r||!s)return void this.logger.warn(`${this.name}: ${t} has no mail or uid, no instance`);const o=this.provider instanceof n,c=i.groups?.org||(o?this.config.twake_instance_cozy_org_id:"")||void 0,h=i.groups?.org?r.split("@")[1]:o&&this.config.twake_instance_cozy_org_domain||void 0,d=this.offers[this.patterns.findIndex(e=>e.test(t))];return{id:this.id(s,i),...o?{oidc:s}:{},twakeId:s,email:r,publicName:first(this.value(e,"displayName"))||first(this.value(e,"cn"))||s,locale:first(this.value(e,"preferredLanguage"))||this.locale,phone:first(this.value(e,"mobile")),...c?{orgId:c}:{},...h?{orgDomain:h}:{},...d?{offer:d}:{}}}match(t){return this.patterns.map(e=>e.exec(t)).find(t=>null!==t)}id(t,e){const i={uid:t,...e.groups};return(this.config.twake_instance_id||"{uid}").replace(/\{(\w+)\}/g,(t,e)=>i[e]??"").replace(/\./g,"").toLowerCase()}value(t,e){const i=e.toLowerCase(),a=Object.keys(t).find(t=>t.toLowerCase()===i);return a?t[a]:void 0}async findByMail(t,e){return(await this.server.ldap.search({paged:!1,scope:"sub",filter:`(mail=${h(t)})`},this.config.ldap_base,e)).searchEntries.find(t=>this.patterns.some(e=>e.test(t.dn)))}}export{TwakeInstances as default};
@@ -1 +1 @@
1
- import{randomUUID as e}from"node:crypto";import t from"node:fs";import r from"../../abstract/plugin.js";import{extractLdapCode as i}from"../../lib/ldapCodes.js";import{unescapeDnValue as s,isDummyMemberDn as n,parseDn as o,normalizeDn as a}from"../../lib/utils.js";import{DEFAULT_LOCK_ATTRIBUTE as c,DEFAULT_LOCK_VALUE as l}from"../scim/mapping.js";import{lifecycleAttributes as h,isTombstone as u,isLocked as d,valueOf as f,first as m,parseDeletedAt as b}from"./lifecycleAttributes.js";const g=["created","roleChanged","disabled","enabled","updated","memberAdded","memberRemoved","deleted"],p=["$members","$added","$removed"];const isObject=e=>"object"==typeof e&&null!==e&&!Array.isArray(e);function optionalString(e,t,r){if(void 0!==e&&"string"!=typeof e)throw new Error(`${t}: "${r}" must be a string`)}const isStrings=e=>isObject(e)&&Object.values(e).every(e=>"string"==typeof e);function optionalCondition(e,t,r){if(void 0!==e){if(!isStrings(e))throw new Error(`${t}: "${r}" must be an object of strings`);return e}}const y=/^(?:[^$].*|\$(?!now$)[A-Za-z][A-Za-z0-9-]*)$/;function memberList(e){if(!isObject(e))return;const t=Object.entries(e);if(1!==t.length)return;const[r,i]=t[0];return p.includes(r)&&isStrings(i)&&Object.values(i).every(e=>y.test(e))?{list:r,fields:i}:void 0}function optionalPayload(e,t,r){if(void 0===e)return;const i=isObject(e)?Object.entries(e):void 0,s={};for(const[e,t]of i||[]){const r="string"==typeof t?t:memberList(t);if(void 0===r)break;s[e]=r}if(!i||Object.keys(s).length!==i.length)throw new Error(`${t}: "${r}" must be an object of strings, or of ${p.join(", ")} each mapped to an object of member attributes ($attr) or plain values`);return s}function dnKey(e){try{return a(e)}catch{return e.toLowerCase()}}function parseRules(e){if(!e.trim())return[];const r=/^\s*[[{]/.test(e)?e:t.readFileSync(e,"utf8"),i=JSON.parse(r);if(!Array.isArray(i))throw new Error("--twake-lifecycle-rules must be a JSON array of rules");return i.map(e=>{if(!isObject(e))throw new Error("Every lifecycle rule must be an object");if("string"!=typeof e.dn)throw new Error('Every lifecycle rule needs a "dn" pattern');const{dn:t}=e,r=`Lifecycle rule ${t}`;optionalString(e.exchange,r,"exchange");const i=optionalPayload(e.payload,r,"payload");if(void 0!==e.events&&!isObject(e.events))throw new Error(`${r}: "events" must be an object`);const s={};for(const[r,n]of Object.entries(e.events||{})){if(!g.includes(r))throw new Error(`Unknown lifecycle event "${r}"; known: ${g.join(", ")}`);const o=`Lifecycle event "${r}" of ${t}`;s[r]=(Array.isArray(n)?n:[n]).map(t=>{if("string"!=typeof t&&!isObject(t))throw new Error(`${o}: a target is a routing key or an object`);const r="string"==typeof t?{routingKey:t}:t;optionalString(r.routingKey,o,"routingKey"),optionalString(r.exchange,o,"exchange");const s=r.exchange||e.exchange,n=r.routingKey;if(!s||!n)throw new Error(`${o} needs an exchange and a routing key`);return{exchange:s,routingKey:n,payload:optionalPayload(r.payload,o,"payload")||i||{},when:optionalCondition(r.when,o,"when")||{}}})}return{dn:new RegExp(t,"i"),targets:s}})}class TwakeLifecycleEvents extends r{constructor(e){if(super(e),this.name="twakeLifecycleEvents",this.roles=["consistency"],this.dependencies={onLdapChange:"core/ldap/onChange",rabbitmq:"core/rabbitmq"},this.hooks={onLdapEntryChange:(e,t,r,i)=>this.onEntryChange(e,t,r,i),twakedeletionreplay:(e,t,r)=>this.replay(e,t,r)},this.attrs=h(this.config),this.memberAttribute=this.config.twake_lifecycle_member_attribute||"member",this.followedOperationalAttributes=[this.attrs.lock],this.rules=parseRules(this.config.twake_lifecycle_rules||""),0===this.rules.length)this.logger.warn(`${this.name}: --twake-lifecycle-rules is empty, nothing will be published`);else if(!this.config.rabbitmq_url)throw new Error(`${this.name}: --twake-lifecycle-rules needs --rabbitmq-url`)}async assertComposition(){if(0!==this.rules.length&&!await(this.requirePlugin("rabbitmq")?.getRawClient()))throw new Error(`${this.name}: RabbitMQ at --rabbitmq-url cannot be reached`)}afterLoad(){const e=this.server.loadedPlugins.scim?.config;if(!e)return;const t=e.scim_user_lock_attribute?.trim()||c,r=e.scim_user_lock_value?.trim()||l,{lock:i,lockValue:s}=this.attrs;t.toLowerCase()===i.toLowerCase()&&r.toLowerCase()===s.toLowerCase()||this.logger.warn(`${this.name}: SCIM locks an account with ${t}: ${r}, these events read ${i}: ${s}, so a SCIM deactivation publishes no disabled`)}async replay(e,t,r){const i=this.match(e);i&&u(t,this.attrs)&&await this.publish(i.rule,"deleted",{before:t,after:t,dn:i.groups,change:r})}match(e){const t=function(e){return o(e).map(e=>{const t=e.indexOf("=");if(-1===t)return e;const r=e.slice(t+1).trim();return`${e.slice(0,t).trim()}=${r.replace(/\\,/g,"\\2C")}`}).join(",")}(e);for(const e of this.rules){const r=e.dn.exec(t);if(!r)continue;const i={};for(const[e,t]of Object.entries(r.groups||{}))void 0!==t&&(i[e]=s(t));return{rule:e,groups:i}}}async onEntryChange(e,t,r,i){const s=this.match(e);if(!s||t&&u(t,this.attrs))return;const{rule:n}=s,o={before:t||{},after:r||t||{},dn:s.groups,change:i};if(!r)return this.publish(n,"deleted",o);if(u(r,this.attrs))return t?this.publish(n,"deleted",o):void 0;if(!t)return await this.publish(n,"created",o),void(d(r,this.attrs)&&await this.publish(n,"disabled",o));this.attrs.role&&!function(e,t){const set=e=>new Set((void 0===e?[]:Array.isArray(e)?e:[e]).map(e=>e.toString().toLowerCase())),r=set(e),i=set(t);return r.size===i.size&&[...r].every(e=>i.has(e))}(f(t,this.attrs.role),f(r,this.attrs.role))&&await this.publish(n,"roleChanged",o);const a=d(r,this.attrs);a!==d(t,this.attrs)&&await this.publish(n,a?"disabled":"enabled",o),await this.publish(n,"updated",o);const c=this.memberDiff(r,t),l=this.memberDiff(t,r);c.length&&await this.publish(n,"memberAdded",{...o,added:c}),l.length&&await this.publish(n,"memberRemoved",{...o,removed:l})}members(e){const t=f(e,this.memberAttribute);return(void 0===t?[]:Array.isArray(t)?t:[t]).map(e=>e.toString()).filter(e=>!n(e,this.config.group_dummy_user))}memberDiff(e,t){const r=new Set(this.members(t).map(dnKey));return this.members(e).filter(e=>!r.has(dnKey(e)))}async memberPayloads({list:e,fields:t},r){const i="$added"===e?r.added||[]:"$removed"===e?r.removed||[]:this.members(r.after),s=Object.values(t).filter(e=>e.startsWith("$")).map(e=>e.slice(1));this.attrs.deleted&&s.push(this.attrs.deleted);const n=[];for(const e of i){const r=await this.readMember(e,s);if(!r||u(r,this.attrs))continue;const i={};for(const[e,s]of Object.entries(t)){const t=s.startsWith("$")?m(f(r,s.slice(1))):s;void 0!==t&&(i[e]=t)}n.push(i)}return n}async readMember(e,t){try{const{searchEntries:r}=await this.server.ldap.search({paged:!1,scope:"base",attributes:t},e);if(r[0])return r[0]}catch(e){if(32!==i(e))throw e}const[r]=o(e),n=r?.indexOf("=")??-1;if(-1!==n)return{[r.slice(0,n).trim()]:s(r.slice(n+1).trim())}}resolve(e,t){if(!e.startsWith("$"))return e;if("$now"===e)return(new Date).toISOString();let r=e.slice(1);const i=r.endsWith("|domain");let s;i&&(r=r.slice(0,-7));let n=r;if(r.startsWith("dn."))s=t.dn[r.slice(3)],n="";else if(r.startsWith("context."))s=t.change[r.slice(8)],n="";else if(r.startsWith("previous."))n=r.slice(9),s=m(f(t.before,n));else if(r.startsWith("changed.")){n=r.slice(8);const e=f(t.after,n);(function(e,t){const list=e=>(void 0===e?[]:Array.isArray(e)?e:[e]).map(e=>e.toString()).sort();return list(e).join("\0")===list(t).join("\0")})(f(t.before,n),e)||(s=m(e)??"")}else s=m(f(t.after,n));return void 0!==s&&this.attrs.deletedAt&&n.toLowerCase()===this.attrs.deletedAt.toLowerCase()&&(s=b(s,this.attrs.deletedAtFormat)?.toISOString()),i&&(s=s?.split("@")[1]),s}async publish(t,r,i){const s=t.targets[r];if(!s?.length)return;const n=this.requirePlugin("rabbitmq");if(n)for(const t of s){if(!Object.entries(t.when).every(([e,t])=>{const r=this.resolve(e,i);return t.startsWith("!")?r!==t.slice(1):r===t}))continue;const s={};let o=!1,a=!1,c=!1;try{for(const[e,r]of Object.entries(t.payload)){if("string"!=typeof r){const t=await this.memberPayloads(r,i);t.length||"$members"===r.list||(c=!0),s[e]=t;continue}const t=this.resolve(r,i);r.startsWith("$changed.")&&(o=!0,void 0!==t&&(a=!0)),void 0!==t&&(s[e]=t)}}catch(e){this.logger.error({plugin:this.name,event:r,routingKey:t.routingKey,result:"members unreadable",error:String(e)});continue}if(c||o&&!a)continue;const l=e(),h={plugin:this.name,event:r,exchange:t.exchange,routingKey:t.routingKey,messageId:l};if(await n.getRawClient())try{await n.publish(t.exchange,t.routingKey,s,{messageId:l}),this.logger.info({...h,result:"published"})}catch(e){this.logger.error({...h,result:"error",error:String(e)})}else this.logger.error({...h,result:"no broker"})}}}export{g as LIFECYCLE_EVENTS,TwakeLifecycleEvents as default,parseRules};
1
+ import{randomUUID as e}from"node:crypto";import t from"node:fs";import r from"../../abstract/plugin.js";import{extractLdapCode as s}from"../../lib/ldapCodes.js";import{unescapeDnValue as i,isDummyMemberDn as n,parseDn as o,normalizeDn as a}from"../../lib/utils.js";import{DEFAULT_LOCK_ATTRIBUTE as c,DEFAULT_LOCK_VALUE as l}from"../scim/mapping.js";import{lifecycleAttributes as h,isTombstone as u,isLocked as d,valueOf as f,first as m,parseDeletedAt as b}from"./lifecycleAttributes.js";const g=["created","roleChanged","disabled","enabled","updated","memberAdded","memberRemoved","deleted"],p=["$members","$added","$removed"];const isObject=e=>"object"==typeof e&&null!==e&&!Array.isArray(e);function optionalString(e,t,r){if(void 0!==e&&"string"!=typeof e)throw new Error(`${t}: "${r}" must be a string`)}const isStrings=e=>isObject(e)&&Object.values(e).every(e=>"string"==typeof e);function optionalCondition(e,t,r){if(void 0!==e){if(!isStrings(e))throw new Error(`${t}: "${r}" must be an object of strings`);return e}}const y=/^(?:[^$].*|\$(?!now$)[A-Za-z][A-Za-z0-9-]*)$/;function memberList(e){if(!isObject(e))return;const t=Object.entries(e);if(1!==t.length)return;const[r,s]=t[0];return p.includes(r)&&isStrings(s)&&Object.values(s).every(e=>y.test(e))?{list:r,fields:s}:void 0}function optionalPayload(e,t,r){if(void 0===e)return;const s=isObject(e)?Object.entries(e):void 0,i={};for(const[e,t]of s||[]){const r="string"==typeof t?t:memberList(t);if(void 0===r)break;i[e]=r}if(!s||Object.keys(i).length!==s.length)throw new Error(`${t}: "${r}" must be an object of strings, or of ${p.join(", ")} each mapped to an object of member attributes ($attr) or plain values`);return i}function dnKey(e){try{return a(e)}catch{return e.toLowerCase()}}function ruleAttributes(e){const t=/^\$(?:previous\.|changed\.)?([A-Za-z][A-Za-z0-9-]*)(?:\|domain)?$/;return e.flatMap(e=>Object.values(e.targets).flat()).flatMap(e=>[...Object.values(e.payload),...Object.keys(e.when)]).flatMap(e=>"string"==typeof e&&t.exec(e)?.[1]||[]).filter(e=>"now"!==e)}function parseRules(e){if(!e.trim())return[];const r=/^\s*[[{]/.test(e)?e:t.readFileSync(e,"utf8"),s=JSON.parse(r);if(!Array.isArray(s))throw new Error("--twake-lifecycle-rules must be a JSON array of rules");return s.map(e=>{if(!isObject(e))throw new Error("Every lifecycle rule must be an object");if("string"!=typeof e.dn)throw new Error('Every lifecycle rule needs a "dn" pattern');const{dn:t}=e,r=`Lifecycle rule ${t}`;optionalString(e.exchange,r,"exchange");const s=optionalPayload(e.payload,r,"payload");if(void 0!==e.events&&!isObject(e.events))throw new Error(`${r}: "events" must be an object`);const i={};for(const[r,n]of Object.entries(e.events||{})){if(!g.includes(r))throw new Error(`Unknown lifecycle event "${r}"; known: ${g.join(", ")}`);const o=`Lifecycle event "${r}" of ${t}`;i[r]=(Array.isArray(n)?n:[n]).map(t=>{if("string"!=typeof t&&!isObject(t))throw new Error(`${o}: a target is a routing key or an object`);const r="string"==typeof t?{routingKey:t}:t;optionalString(r.routingKey,o,"routingKey"),optionalString(r.exchange,o,"exchange");const i=r.exchange||e.exchange,n=r.routingKey;if(!i||!n)throw new Error(`${o} needs an exchange and a routing key`);return{exchange:i,routingKey:n,payload:optionalPayload(r.payload,o,"payload")||s||{},when:optionalCondition(r.when,o,"when")||{}}})}return{dn:new RegExp(t,"i"),targets:i}})}class TwakeLifecycleEvents extends r{constructor(e){if(super(e),this.name="twakeLifecycleEvents",this.roles=["consistency"],this.dependencies={onLdapChange:"core/ldap/onChange",rabbitmq:"core/rabbitmq"},this.hooks={onLdapEntryChange:(e,t,r,s)=>this.onEntryChange(e,t,r,s),twakedeletionreplay:(e,t,r)=>this.replay(e,t,r)},this.attrs=h(this.config),this.memberAttribute=this.config.twake_lifecycle_member_attribute||"member",this.rules=parseRules(this.config.twake_lifecycle_rules||""),this.followedOperationalAttributes=[this.attrs.lock,...ruleAttributes(this.rules)],0===this.rules.length)this.logger.warn(`${this.name}: --twake-lifecycle-rules is empty, nothing will be published`);else if(!this.config.rabbitmq_url)throw new Error(`${this.name}: --twake-lifecycle-rules needs --rabbitmq-url`)}async assertComposition(){if(0!==this.rules.length&&!await(this.requirePlugin("rabbitmq")?.getRawClient()))throw new Error(`${this.name}: RabbitMQ at --rabbitmq-url cannot be reached`)}afterLoad(){const e=this.server.loadedPlugins.scim?.config;if(!e)return;const t=e.scim_user_lock_attribute?.trim()||c,r=e.scim_user_lock_value?.trim()||l,{lock:s,lockValue:i}=this.attrs;t.toLowerCase()===s.toLowerCase()&&r.toLowerCase()===i.toLowerCase()||this.logger.warn(`${this.name}: SCIM locks an account with ${t}: ${r}, these events read ${s}: ${i}, so a SCIM deactivation publishes no disabled`)}async replay(e,t,r){const s=this.match(e);s&&u(t,this.attrs)&&await this.publish(s.rule,"deleted",{before:t,after:t,dn:s.groups,change:r})}match(e){const t=function(e){return o(e).map(e=>{const t=e.indexOf("=");if(-1===t)return e;const r=e.slice(t+1).trim();return`${e.slice(0,t).trim()}=${r.replace(/\\,/g,"\\2C")}`}).join(",")}(e);for(const e of this.rules){const r=e.dn.exec(t);if(!r)continue;const s={};for(const[e,t]of Object.entries(r.groups||{}))void 0!==t&&(s[e]=i(t));return{rule:e,groups:s}}}async onEntryChange(e,t,r,s){const i=this.match(e);if(!i||t&&u(t,this.attrs))return;const{rule:n}=i,o={before:t||{},after:r||t||{},dn:i.groups,change:s};if(!r)return this.publish(n,"deleted",o);if(u(r,this.attrs))return t?this.publish(n,"deleted",o):void 0;if(!t)return await this.publish(n,"created",o),void(d(r,this.attrs)&&await this.publish(n,"disabled",o));this.attrs.role&&!function(e,t){const set=e=>new Set((void 0===e?[]:Array.isArray(e)?e:[e]).map(e=>e.toString().toLowerCase())),r=set(e),s=set(t);return r.size===s.size&&[...r].every(e=>s.has(e))}(f(t,this.attrs.role),f(r,this.attrs.role))&&await this.publish(n,"roleChanged",o);const a=d(r,this.attrs);a!==d(t,this.attrs)&&await this.publish(n,a?"disabled":"enabled",o),await this.publish(n,"updated",o);const c=this.memberDiff(r,t),l=this.memberDiff(t,r);c.length&&await this.publish(n,"memberAdded",{...o,added:c}),l.length&&await this.publish(n,"memberRemoved",{...o,removed:l})}members(e){const t=f(e,this.memberAttribute);return(void 0===t?[]:Array.isArray(t)?t:[t]).map(e=>e.toString()).filter(e=>!n(e,this.config.group_dummy_user))}memberDiff(e,t){const r=new Set(this.members(t).map(dnKey));return this.members(e).filter(e=>!r.has(dnKey(e)))}async memberPayloads({list:e,fields:t},r){const s="$added"===e?r.added||[]:"$removed"===e?r.removed||[]:this.members(r.after),i=Object.values(t).filter(e=>e.startsWith("$")).map(e=>e.slice(1));this.attrs.deleted&&i.push(this.attrs.deleted);const n=[];for(const e of s){const r=await this.readMember(e,i);if(!r||u(r,this.attrs))continue;const s={};for(const[e,i]of Object.entries(t)){const t=i.startsWith("$")?m(f(r,i.slice(1))):i;void 0!==t&&(s[e]=t)}n.push(s)}return n}async readMember(e,t){try{const{searchEntries:r}=await this.server.ldap.search({paged:!1,scope:"base",attributes:t},e);if(r[0])return r[0]}catch(e){if(32!==s(e))throw e}const[r]=o(e),n=r?.indexOf("=")??-1;if(-1!==n)return{[r.slice(0,n).trim()]:i(r.slice(n+1).trim())}}resolve(e,t){if(!e.startsWith("$"))return e;if("$now"===e)return(new Date).toISOString();let r=e.slice(1);const s=r.endsWith("|domain");let i;s&&(r=r.slice(0,-7));let n=r;if(r.startsWith("dn."))i=t.dn[r.slice(3)],n="";else if(r.startsWith("context."))i=t.change[r.slice(8)],n="";else if(r.startsWith("previous."))n=r.slice(9),i=m(f(t.before,n));else if(r.startsWith("changed.")){n=r.slice(8);const e=f(t.after,n);(function(e,t){const list=e=>(void 0===e?[]:Array.isArray(e)?e:[e]).map(e=>e.toString()).sort();return list(e).join("\0")===list(t).join("\0")})(f(t.before,n),e)||(i=m(e)??"")}else i=m(f(t.after,n));return void 0!==i&&this.attrs.deletedAt&&n.toLowerCase()===this.attrs.deletedAt.toLowerCase()&&(i=b(i,this.attrs.deletedAtFormat)?.toISOString()),s&&(i=i?.split("@")[1]),i}async publish(t,r,s){const i=t.targets[r];if(!i?.length)return;const n=this.requirePlugin("rabbitmq");if(n)for(const t of i){if(!Object.entries(t.when).every(([e,t])=>{const r=this.resolve(e,s);return t.startsWith("!")?r!==t.slice(1):r===t}))continue;const i={};let o=!1,a=!1,c=!1;try{for(const[e,r]of Object.entries(t.payload)){if("string"!=typeof r){const t=await this.memberPayloads(r,s);t.length||"$members"===r.list||(c=!0),i[e]=t;continue}const t=this.resolve(r,s);r.startsWith("$changed.")&&(o=!0,void 0!==t&&(a=!0)),void 0!==t&&(i[e]=t)}}catch(e){this.logger.error({plugin:this.name,event:r,routingKey:t.routingKey,result:"members unreadable",error:String(e)});continue}if(c||o&&!a)continue;const l=e(),h={plugin:this.name,event:r,exchange:t.exchange,routingKey:t.routingKey,messageId:l};if(await n.getRawClient())try{await n.publish(t.exchange,t.routingKey,i,{messageId:l}),this.logger.info({...h,result:"published"})}catch(e){this.logger.error({...h,result:"error",error:String(e)})}else this.logger.error({...h,result:"no broker"})}}}export{g as LIFECYCLE_EVENTS,TwakeLifecycleEvents as default,parseRules};
@@ -0,0 +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 r}from"../../lib/operation.js";import{escapeDnValue as i,escapeLdapFilter as n,parseDn as o}from"../../lib/utils.js";import{organizationIn as h,dnKey as c,movedInto as l,ORG as d,branchPattern as p,values as u,parentOf as g,read as m,rdnValue as f,notFound as w,invalid as b,bodyOf as y,isDisplayName as v,NAME_RULE as k,RouteError as O}from"./groups.js";import{isTombstone as $,valueOf as I,lifecycleAttributes as _}from"./lifecycleAttributes.js";const C=["viewer","editor","admin"],isRole=e=>C.includes(e),L=[...C].reverse(),M=["name"],S=["uid","displayName","mail","jobTitle","role"],j=new RegExp(`:(${C.join("|")})$`,"i"),conflict=(e,t)=>new O(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 b(`role must be one of: ${C.join(", ")}`);return e.role}function checkLength(e,t,s){if(e.length>s)throw b(`${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 b(`${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 b(`${a} must be an array of { ${t}, role }`);checkLength(e,a,s);const r=new Set;return e.map(e=>{const{[t]:s,role:i}=e??{};if("string"!=typeof s||0===s.length||!isRole(i))throw b(`${a} must be an array of { ${t}, role }, role one of: ${C.join(", ")}`);if(r.has(s.toLowerCase()))throw b(`${s} is listed twice in ${a}`);return r.add(s.toLowerCase()),{name:s,role:i}})}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.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 r=l(this.spacePattern,e,t);if(void 0===r)return[e,t,a];const i=Object.values(this.roleAttributes),n=await this.entry(e,i);if(n&&i.some(e=>void 0!==this.foreign(r,I(n,e))))throw new s(`The space holds users or groups of another organization than ${r}`);return[e,t,a]},ldapdeleterequest:async([e,t])=>{const s=r();if(void 0!==s)for(const t of[e].flat()){const e=h(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(c(t),{org:e,ids:a})}else if(this.rabbitmq){const e=await this.deletedGroup(t);e&&this.unlinking.set(c(t),{...e,op:s})}}return[e,t]},ldapdeletedone:(e,t={})=>{const s=r(),a=void 0===s?void 0:this.handing.get(s);for(const s of[e].flat()){const e=this.unlinking.get(c(s));e&&delete e.op;const r=a?.get(c(s));r&&this.queue(s,()=>this.handOver(r.org,s,r.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)=>{this.follows&&this.queue(e,()=>this.follow(e,t,s,a));const r=h(this.userPattern,e);void 0!==r&&this.lifecycle.deleted&&t&&s&&!$(t,this.lifecycle)&&$(s,this.lifecycle)&&this.queue(e,async()=>this.handOver(r,e,await this.administered(r,e),a))}},this.spaceBase=this.config.twake_space_base||"",!this.spaceBase.includes(d))throw new Error(`${this.name}: --twake-space-base must hold ${d}`);this.spacePattern=p(this.spaceBase),this.userPattern=p(this.config.twake_group_user_base),this.lifecycle=_(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.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.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=o(e).reverse();return e=>o(e).reverse().slice(0,t.length).every((e,s)=>t[s].includes(d)?type(e)===type(t[s]):c(e)===c(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 h(this.spacePattern,e)}spaceBaseOf(e){return this.spaceBase.replace(d,i(e))}spaceDn(e,t){return`cn=${i(t)},${this.spaceBaseOf(e)}`}async entry(e,t){try{return(await this.server.ldap.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 r=this.foreign(a,t);if(void 0!==r)throw new s(`${r} is neither a user nor a group of organization ${a}`)}foreign(e,t){const s=[this.groups.userBaseOf(e),this.groups.groupBaseOf(e)].map(c);return u(t).find(e=>!s.includes(c(g(e))))}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=y(e),r=Object.keys(a).filter(e=>"name"!==e);if(r.length)throw b(`Only "name" can be updated; unsupported field(s): ${r.join(", ")}`);if(!v(a.name))throw b(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 w("space");throw e}done(t)})),e.get(`${t}/:spaceId/members`,route("listing space members",async(e,t,s)=>{const a=this.groups.page(e,S),r=await this.space(s,spaceId(e),e),i=r.holders.filter(e=>"member"===e.kind),n=new Map(i.map(e=>[e.name.toLowerCase(),e.role]));t.json({organizationId:s,id:r.id,...await this.groups.memberPage(a,s,i.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=y(e),r=namesOf(a.usernames,"usernames",this.groups.maxPage);await this.add(e,s,"member",roleOf(a),()=>this.liveUsers(s,r,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(y(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),r=a.holders.filter(e=>"group"===e.kind),i=new Map((await this.orgGroups(s,r.map(e=>e.name),[],e)).map(e=>[c(e.dn),this.groups.groupOf(s,e).displayName])),n=r.map(({name:e,role:t,dn:s})=>({id:e,name:i.get(c(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=y(e),r=namesOf(a.groupIds,"groupIds",this.groups.maxPage);await this.add(e,s,"group",roleOf(a),()=>this.groupDns(s,r,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(y(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)}))}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([[c(this.groups.userBaseOf(e)),"member"],[c(this.groups.groupBaseOf(e)),"group"]]),r=m(t,"cn")||"",i=new Map;for(const e of L)for(const r of u(I(t,this.roleAttributes[e]))){const t=c(r),n=a.get(c(g(r)));if(!n||s.has(t))continue;const o=i.get(t);o?o.roles.push(e):i.set(t,{kind:n,name:f(r),role:e,roles:[e],dn:r})}return{id:r,name:m(t,this.displayName)??r,holders:[...i.values()]}}async spaces(e,t,s,r,i){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),r)).searchEntries}catch(e){if(32===a(e))return[];throw e}})(),i??this.hidden(e)]);return n.map(t=>this.entryOf(e,t,o))}async hidden(e){return new Set((await this.groups.tombstonesOf(e)).map(c))}holding(e){return`(|${e.flatMap(e=>Object.values(this.roleAttributes).map(t=>`(${t}=${n(e)})`)).join("")})`}async space(e,t,s){const[a]=await this.spaces(e,"(objectClass=*)",t,s);if(!a)throw w("space");return a}async write(e,t,s,r){try{await this.server.ldap.modify(this.spaceDn(e,t),s,r)}catch(e){if(32===a(e))throw w("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,r=new Map((await this.groups.users(e,t,[a],s)).map(e=>[(m(e,a)||"").toLowerCase(),e.dn]));if(t.some(e=>!r.has(e.toLowerCase())))throw w("user");return r}get groupName(){return this.config.twake_group_display_name_attribute||"twakeDisplayName"}async orgGroups(e,t,s=[],a){const r=this.groups.cn,i=[];for(let o=0;o<t.length;o+=this.groups.maxPage){const h=t.slice(o,o+this.groups.maxPage);i.push(...await this.groups.groups(e,`(|${h.map(e=>`(${r}=${n(e)})`).join("")})`,[r,this.groupName,...s],a))}return i}async follow(e,t,s,a){let r=this.organizationOf(e);if(void 0!==r){const[e,i]=[t,s].map(e=>e?this.entryOf(r,e,new Set):void 0),n=[...new Set([e,i].flatMap(e=>e?[e.id]:[]))],o=[e,i].map(e=>new Map(e?.holders.map(e=>[c(e.dn),e.role]))),h=[e,i].flatMap(e=>e?.holders??[]).filter(e=>n.length>1||o[0].get(c(e.dn))!==o[1].get(c(e.dn))),l=new Set(h.filter(e=>"group"===e.kind).map(e=>e.name)),d=await this.orgGroups(r,[...l],["member"]),p=(i??e).id,g=await this.settle(r,[...h.filter(e=>"member"===e.kind).map(e=>e.dn),...d.flatMap(e=>u(e.member))],{id:p,space:e},await this.hidden(r));return void(this.rabbitmq&&await this.announce(r,g,a,n,{was:e,is:i,linked:d}))}if(r=this.groups.organizationOf(e),void 0===r||!t)return;const i=u(t.member),n=u(s?.member),[o,h]=[i,n].map(e=>new Set(e.map(c))),l=s?void 0:this.unlinking.get(c(e));l&&this.unlinking.delete(c(e));const d=await this.hidden(r),p=await this.settle(r,[...i.filter(e=>!h.has(c(e))),...n.filter(e=>!o.has(c(e)))],{group:e,members:o,spaces:l?.spaces},d);if(!this.rabbitmq||!l&&!p.length)return;const g=(await this.spaces(r,this.holding([e]),void 0,void 0,d)).map(e=>e.id);l&&this.unlinked(e,l,g,a),await this.announce(r,p,a,[...l?.spaces.map(e=>e.id)??[],...g])}async administered(e,t){try{const s=`(${this.roleAttributes.admin}=${n(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 r of s)try{await this.again(()=>this.handOverOnce(e,t,r,a))}catch(e){this.logger.error({plugin:this.name,event:"handOver",space:r,error:String(e)})}}async handOverOnce(e,t,s,a){const r=c(t),[i]=await this.spaces(e,"(objectClass=*)",s),n=i?.holders.filter(e=>"member"===e.kind&&c(e.dn)!==r)??[];if(!i||n.some(e=>"admin"===e.role))return;if(!n.length)return void await this.server.ldap.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.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=>c(t.dn)===c(e));return s?[{id:t.id,role:s.role}]:[]});if(!s.length)return;const[a]=await this.orgGroups(t,[f(e)]),r=f(e);return{org:t,id:r,name:a&&m(a,this.groupName)||r,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:r,id:i,name:n,spaces:o}=t,h=new Set(s.map(e=>e.toLowerCase()));for(const{id:t,role:s}of o){const o=c(this.spaceDn(r,t));h.has(t.toLowerCase())&&(this.announced.has(o)||this.announced.set(o,new Set),this.announced.get(o).add(c(e))),this.publishEvent(r,t,"group.unlinked",a,{groups:[{id:i,name:n,role:s}]})}}async settle(e,t,s,a){const r=c(this.groups.userBaseOf(e)),i=new Map(t.filter(e=>c(g(e))===r).map(e=>[c(e),e])),n=[];for(const[t,r]of i)if(!a.has(t))try{const t=await this.rolesOf(e,r,a),i=this.userRole?await this.writeRoles(r,t):await this.rolesOf(e,r,a,s);i&&n.push({dn:r,before:i,after:t})}catch(e){this.logger.error({plugin:this.name,event:"settle",dn:r,error:String(e)})}return n}async userIn(e,t,s,a){const r=(await this.groups.groups(e,`(member=${n(t)})`,[this.groups.cn],s)).map(e=>c(e.dn)),i=new Set([c(t),...r]),o=[t,...r];if(a&&"group"in a){const e=c(a.group);i.delete(e),a.members.has(c(t))&&i.add(e),o.push(a.group)}return{filter:this.holding(o),roleIn:e=>e.holders.find(e=>i.has(c(e.dn)))?.role}}async announce(e,t,s,a,r){const i=new Set;if(r){const{was:a,is:n,linked:o}=r,h=(n??a).id,l=new Map(o.map(e=>[c(e.dn),m(e,this.groupName)||f(e.dn)])),groupOf=e=>({id:e.name,name:l.get(c(e.dn))??e.name,role:e.role}),publish=(t,a)=>this.publishEvent(e,h,t,s,a),d=c(this.spaceDn(e,h)),p=new Set;for(const t of[a,n]){const s=t&&c(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=>[c(e.dn),e]));for(const t of n.holders.filter(e=>"group"===e.kind)){const s=e.get(c(t.dn));e.delete(c(t.dn)),s?.role!==t.role&&publish(s?"group.role.changed":"group.linked",{groups:[groupOf(t)]})}const byDeletion=e=>{if(p.delete(c(e.dn)))return!0;const t=this.unlinking.get(c(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,r]=[a,n].map(e=>new Set(e.holders.map(e=>c(e.dn)))),i=[...p].filter(e=>t.has(e)&&r.has(e));i.length&&this.announced.set(d,new Set(i))}else if(i.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(c(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[r,o]=[t.get(a),s.get(a)];if(r===o||i.has(a.toLowerCase()))return[];const h=!n.has(a.toLowerCase());if(h&&!this.userRole)return[];return[{dn:e,id:a,event:r?o?"member.role.changed":"member.removed":"member.added",role:o??r,caught:h}]}));if(!o.length)return;const h=await this.profiles(e,o.map(e=>e.dn));for(const{dn:t,id:a,event:r,role:i,caught:n}of o){const o=h.get(c(t));o&&this.publishEvent(e,a,r,n?{}:s,{members:[{...o,role:i}]})}}async profiles(e,t){if(!t.length)return new Map;const s=this.groups.userAttribute,a=this.config.mail_attribute||"mail",r=new Map((await this.groups.users(e,[...new Set(t.map(f))],[s,"entryUUID",a,"givenName","sn"])).map(e=>[(m(e,s)||"").toLowerCase(),e]));return new Map(t.flatMap(e=>{const t=r.get(f(e).toLowerCase());if(!t)return[];const i={uuid:m(t,"entryUUID"),username:m(t,s),email:m(t,a),firstName:m(t,"givenName"),lastName:m(t,"sn")};return[[c(e),i]]}))}publishEvent(e,t,s,a,r={}){const i=this.rabbitmq;if(!i)return;const n={organizationId:e,id:t,...r,actor:a.actor,timestamp:(new Date).toISOString()};this.publishing=this.publishing.then(()=>this.publish(i,s,n))}get rabbitmq(){return this.config.rabbitmq_url?this.requirePlugin("rabbitmq"):null}async publish(t,s,a){const r={plugin:this.name,exchange:this.exchange,routingKey:`twake.space.${s}`,messageId:e()};try{if(!await t.getRawClient())return void this.logger.error({...r,result:"no broker"});await t.publish(r.exchange,r.routingKey,a,{messageId:r.messageId}),this.logger.info({...r,result:"published"})}catch(e){this.logger.error({...r,result:"error",error:String(e)})}}async rolesOf(e,t,s,a){const{filter:r,roleIn:i}=await this.userIn(e,t,void 0,a);let n=await this.spaces(e,r,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=i(e);return t?[[e.id,t]]:[]}));if(a&&"group"in a&&a.members.has(c(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,r=[...t].map(([e,t])=>`${e}:${t}`),i=new Set(r.map(e=>e.toLowerCase()));for(let t=0;;t++)try{const[t]=(await this.server.ldap.search({paged:!1,scope:"base",attributes:[s]},e)).searchEntries;if(!t)return;const a=u(I(t,s)),n=new Set(a.map(e=>e.toLowerCase())),o=a.filter(e=>j.test(e)&&!i.has(e.toLowerCase())),h=r.filter(e=>!n.has(e.toLowerCase()));return(o.length||h.length)&&await this.server.ldap.modify(e,{...o.length&&{delete:{[s]:o}},...h.length&&{add:{[s]:h}}}),new Map(a.flatMap(e=>{const t=j.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=>[(m(e,this.groups.cn)||"").toLowerCase(),e.dn]));if(t.some(e=>!a.has(e.toLowerCase())))throw w("group");return a}async add(e,t,s,a,r){await this.again(()=>this.addOnce(e,t,s,a,r))}async addOnce(e,t,s,a,r){const i=await this.space(t,e.params.spaceId,e),n=await r(),o=new Map(i.holders.filter(e=>e.kind===s).map(e=>[e.name.toLowerCase(),e.role])),h=[];for(const[e,t]of n){const r=o.get(e);if(void 0===r)h.push(t);else if(r!==a)throw"member"===s?conflict(`${e} is already a member, as ${r}`,"MEMBER_EXISTS"):conflict(`${e} is already linked, as ${r}`,"GROUP_ALREADY_LINKED")}h.length&&await this.write(t,i.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 r=await this.space(t,e.params.spaceId,e),i="member"===s?e.params.userId:e.params.groupId,n=r.holders.find(e=>e.kind===s&&e.name.toLowerCase()===i.toLowerCase());if(!n)throw w(s);if(1===n.roles.length&&n.role===a)return;const o="member"===s&&"admin"===n.role&&"admin"!==a;if(o&&!r.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,r.id,{delete:h,add:c},e),o&&!(await this.space(t,r.id,e)).holders.some(isAdmin))throw await this.write(t,r.id,{add:h,delete:c},e),lastAdmin()}async listRoute(e,t,s){const a=this.groups.page(e,M);let r="(cn=*)";a.search&&(r=`(&${r}(${this.displayName}=*${n(a.search)}*))`);const i=e.query.user;let o;if(void 0!==i){if("string"!=typeof i||!i)throw b("user must be a username");const[t]=(await this.liveUsers(s,[i],e)).values(),a=await this.userIn(s,t,e);r=`(&${r}${a.filter})`,o=a.roleIn}const h=this.groups.sorted((await this.spaces(s,r,void 0,e)).map(e=>({...this.spaceOf(s,e),...o&&{role:o(e)}})),a.sortBy,a.desc);t.json({organizationId:s,spaces:h.slice(a.offset,a.offset+a.limit),pagination:{page:a.page,limit:a.limit,total:h.length,totalPages:Math.ceil(h.length/a.limit)}})}async createRoute(t,s,a){const r=y(t);if(!v(r.name))throw b(k);const i=this.groups.maxPage,n=holdersOf(r.members,"username",i),o=holdersOf(r.groups??[],"id",i);if(!n.some(e=>"admin"===e.role))throw b("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)]),l=e(),d={objectClass:this.config.twake_space_class||["top","twakeSpace"],cn:l,[this.displayName]:r.name};for(const[e,t]of[[n,h],[o,c]])for(const{name:s,role:a}of e){const e=this.roleAttributes[a];d[e]=[...u(d[e]),t.get(s.toLowerCase())]}await this.server.ldap.add(this.spaceDn(a,l),d,t),s.status(201).json(this.spaceOf(a,await this.space(a,l,t)))}}export{C as SPACE_ROLES,TwakeSpaces as default};
@@ -1 +1 @@
1
- import e from"../../abstract/plugin.js";import{BadRequestError as t,NotFoundError as s,ConflictError as r}from"../../lib/errors.js";import{jsonBody as a}from"../../lib/expressFormatedResponses.js";import{changeContext as i}from"../../lib/changeContext.js";import{extractLdapCode as o}from"../../lib/ldapCodes.js";import{escapeLdapFilter as n,asyncHandler as h,isDnInBranch as l,launchHooks as d}from"../../lib/utils.js";import{isTombstone as c,lifecycleAttributes as f,first as m,valueOf as p,parseDeletedAt as u,formatDeletedAt as w}from"./lifecycleAttributes.js";const g=/^[A-Za-z0-9_.-]{1,64}$/;class TwakeTombstone extends e{constructor(e){super(e),this.name="twakeTombstone",this.roles=["consistency","api"],this.reasonFor=new Map,this.erasing=new Set,this.hooks={ldapdeleterequest:async([e,t])=>{const s=[];for(const r of Array.isArray(e)?e:[e]){if(!this.erasing.has(r)&&this.matches(r)){const e=await this.read(r);if(e){await this.writeTombstone(r,e,this.reasonOf(r,t),t);continue}}s.push(r)}return[s,t]},ldapaddrequest:async([e,t,s])=>{if(this.isScim(s)&&this.matches(e)){const t=await this.read(e);t&&c(t,this.attrs)&&await this.replace(e,t,s)}return[e,t,s]},ldapsearchrequest:([e,t,s])=>{if(!this.isScim(s))return[e,t,s];const r=t.filter||"(objectClass=*)",a=`(!(${this.attrs.deleted}=${n(this.attrs.deletedValue)}))`;return[e,{...t,filter:`(&${String(r).startsWith("(")?String(r):`(${String(r)})`}${a})`},s]}};const t=this.config;if(this.attrs=f(t),!this.attrs.deleted)throw new Error(`${this.name}: --twake-lifecycle-deleted-attribute is required`);this.patterns=(t.twake_tombstone_dn||[]).map(e=>new RegExp(e,"i")),this.reasons=t.twake_tombstone_reasons||[],this.defaultReason=this.checkReason(t.twake_tombstone_default_reason||"deleted"),this.reasonHeader=(t.twake_tombstone_reason_header||"x-deletion-reason").toLowerCase(),this.clearAttributes=t.twake_tombstone_clear_attributes||[],this.eraseMinAge=t.twake_tombstone_erase_min_age??2592e3,this.groupBases=t.twake_tombstone_group_bases?.length?t.twake_tombstone_group_bases:[t.ldap_group_base||t.ldap_base||""],this.scimPrefix=t.scim_prefix||"/scim/v2",0===this.patterns.length&&this.logger.warn(`${this.name}: --twake-tombstone-dn is empty, every delete stays a delete`)}afterLoad(){for(const e of["ldapdeleterequest","ldapaddrequest"]){const t=this.server.hooks[e],s=this.hooks[e],r=t&&s?t.indexOf(s):-1;r<0||(t.splice(r,1),t.push(s))}}api(e){e.post(`${this.config.api_prefix}/v1/twake/tombstones/erase`,h(async(e,s)=>{const r=a(e,s,"dn");if(r){if("string"!=typeof r.dn)throw new t("Field dn must be a string");await this.erase(r.dn,{force:!0===r.force,req:e}),s.json({success:!0})}}))}async tombstone(e,s,r){if(!this.matches(e))throw new t(`${e} is not an entry that becomes a tombstone`);this.reasonFor.set(e,this.checkReason(s));try{await this.server.ldap.delete(e,r)}finally{this.reasonFor.delete(e)}}async erase(e,{force:t=!1,req:a}={}){const i=await this.read(e);if(!i||!c(i,this.attrs))throw new s(`No tombstone at ${e}`);if(!t){const t=m(p(i,this.attrs.deletedAt)),s=t&&u(t,this.attrs.deletedAtFormat);if(!s)throw new r(`${e} holds no readable deletion date (--twake-lifecycle-deleted-at-attribute); force it to erase now`);if(Date.now()-s.getTime()<1e3*this.eraseMinAge)throw new r(`${e} was deleted less than ${this.eraseMinAge} seconds ago; force it to erase now`)}this.erasing.add(e);try{await this.server.ldap.delete(e,a)}finally{this.erasing.delete(e)}await this.leaveGroups(e),this.logger.info({plugin:this.name,event:"erase",dn:e,force:t})}assertComposition(){if(!this.server.loadedPlugins.trash)return;const e=String(this.config.trash_watched_bases||"").split(";").map(e=>e.trim()).filter(Boolean),t=this.patterns.filter(t=>{const s=t.source.replace(/\$$/,"");return 0===e.length||e.some(e=>l(s,e)||l(e,s))});if(t.length)throw new Error(`${this.name}: core/ldap/trash watches the branch of ${t.map(e=>e.source).join(", ")}, and would move those entries instead of leaving a tombstone. Leave that branch out of --trash-watched-bases, or load one of the two plugins only`)}async replace(e,t,s){const r=await this.groupsOf(e),a=this.config.group_dummy_user,i=new Set(a?await this.groupsOf(a):[]);await this.erase(e,{force:!0,req:s});const o=s.res;if(!o)return;const n=o.end.bind(o);o.end=(...s)=>{o.end=n;const a=n(...s);return this.restore(e,t,r,i).catch(t=>this.logger.error({plugin:this.name,event:"restore",dn:e,error:String(t)})),a}}async restore(e,t,s,r){if(await this.read(e))return;const a=Object.fromEntries(Object.entries(t).filter(([e,t])=>"dn"!==e&&!(Array.isArray(t)&&0===t.length)));await this.server.ldap.add(e,a);const i=this.config.group_dummy_user;for(const t of s)await this.server.ldap.modify(t,{add:{member:e}}),i&&!r.has(t)&&await this.server.ldap.modify(t,{delete:{member:i}}).catch(e=>{if(16!==o(e))throw e});this.logger.warn({plugin:this.name,event:"restore",dn:e})}matches(e){return this.patterns.some(t=>t.test(e))}isScim(e){const t=e?.originalUrl??e?.url;return"string"==typeof t&&t.startsWith(this.scimPrefix)}checkReason(e){if(!(this.reasons.length?this.reasons.includes(e):g.test(e)))throw new t(`Unknown deletion reason "${e}"${this.reasons.length?`; known: ${this.reasons.join(", ")}`:""}`);return e}reasonOf(e,t){const s=this.reasonFor.get(e);if(s)return s;const r=t?.headers?.[this.reasonHeader],a=Array.isArray(r)?r[0]:r;return a?this.checkReason(a):this.defaultReason}async read(e){try{return(await this.server.ldap.search({paged:!1,scope:"base",attributes:["*",this.attrs.lock]},e)).searchEntries[0]}catch(e){if(32===o(e))return;throw e}}async writeTombstone(e,t,s,r){const{attrs:a}=this;if(c(t,a))return d(this.server.hooks.twakedeletionreplay,e,t,i(r)),void this.logger.info({plugin:this.name,event:"replay",dn:e});const o={[a.deleted]:a.deletedValue,[a.lock]:a.lockValue};a.deletedAt&&(o[a.deletedAt]=w(new Date,a.deletedAtFormat)),a.reason&&(o[a.reason]=s);const n=this.clearAttributes.filter(e=>void 0!==p(t,e));await this.server.ldap.modify(e,n.length?{replace:o,delete:n}:{replace:o},void 0,{context:i(r)}),this.logger.info({plugin:this.name,event:"tombstone",dn:e,reason:s})}async groupsOf(e){const t=[];for(const s of this.groupBases.filter(Boolean))try{const r=await this.server.ldap.search({paged:!1,scope:"sub",filter:`(member=${n(e)})`,attributes:["dn"]},s);t.push(...r.searchEntries.map(e=>e.dn))}catch(e){if(32!==o(e))throw e}return t}async leaveGroups(e){for(const t of await this.groupsOf(e))await this.leaveGroup(t,e)}async leaveGroup(e,t){const s=this.config.group_dummy_user;let r=!1;for(let a=0;;a++)try{return void await this.server.ldap.modify(e,r?{add:{member:s},delete:{member:t}}:{delete:{member:t}})}catch(e){const t=o(e);if(16===t)return;if(a>=2)throw e;if(65===t&&s)r=!0;else{if(20!==t)throw e;r=!1}}}}export{TwakeTombstone as default};
1
+ import e from"../../abstract/plugin.js";import{BadRequestError as t,NotFoundError as s,ConflictError as r}from"../../lib/errors.js";import{jsonBody as a}from"../../lib/expressFormatedResponses.js";import{changeContext as i}from"../../lib/changeContext.js";import{extractLdapCode as o}from"../../lib/ldapCodes.js";import{escapeLdapFilter as n,asyncHandler as h,isDnInBranch as l,launchHooks as d}from"../../lib/utils.js";import{isTombstone as c,lifecycleAttributes as f,first as m,valueOf as p,parseDeletedAt as u,formatDeletedAt as w}from"./lifecycleAttributes.js";const g=/^[A-Za-z0-9_.-]{1,64}$/;class TwakeTombstone extends e{constructor(e){super(e),this.name="twakeTombstone",this.roles=["consistency","api"],this.reasonFor=new Map,this.erasing=new Set,this.hooks={ldapdeleterequest:async([e,t])=>{const s=[];for(const r of Array.isArray(e)?e:[e]){if(this.keeps(r)){const e=await this.read(r);if(e){await this.writeTombstone(r,e,this.reasonOf(r,t),t);continue}}s.push(r)}return[s,t]},ldapaddrequest:async([e,t,s])=>{if(this.isScim(s)&&this.matches(e)){const t=await this.read(e);t&&c(t,this.attrs)&&await this.replace(e,t,s)}return[e,t,s]},ldapsearchrequest:([e,t,s])=>{if(!this.isScim(s))return[e,t,s];const r=t.filter||"(objectClass=*)",a=`(!(${this.attrs.deleted}=${n(this.attrs.deletedValue)}))`;return[e,{...t,filter:`(&${String(r).startsWith("(")?String(r):`(${String(r)})`}${a})`},s]}};const t=this.config;if(this.attrs=f(t),!this.attrs.deleted)throw new Error(`${this.name}: --twake-lifecycle-deleted-attribute is required`);this.patterns=(t.twake_tombstone_dn||[]).map(e=>new RegExp(e,"i")),this.reasons=t.twake_tombstone_reasons||[],this.defaultReason=this.checkReason(t.twake_tombstone_default_reason||"deleted"),this.reasonHeader=(t.twake_tombstone_reason_header||"x-deletion-reason").toLowerCase(),this.clearAttributes=t.twake_tombstone_clear_attributes||[],this.eraseMinAge=t.twake_tombstone_erase_min_age??2592e3,this.groupBases=t.twake_tombstone_group_bases?.length?t.twake_tombstone_group_bases:[t.ldap_group_base||t.ldap_base||""],this.scimPrefix=t.scim_prefix||"/scim/v2",0===this.patterns.length&&this.logger.warn(`${this.name}: --twake-tombstone-dn is empty, every delete stays a delete`)}afterLoad(){for(const e of["ldapdeleterequest","ldapaddrequest"]){const t=this.server.hooks[e],s=this.hooks[e],r=t&&s?t.indexOf(s):-1;r<0||(t.splice(r,1),t.push(s))}}api(e){e.post(`${this.config.api_prefix}/v1/twake/tombstones/erase`,h(async(e,s)=>{const r=a(e,s,"dn");if(r){if("string"!=typeof r.dn)throw new t("Field dn must be a string");await this.erase(r.dn,{force:!0===r.force,req:e}),s.json({success:!0})}}))}async tombstone(e,s,r){if(!this.matches(e))throw new t(`${e} is not an entry that becomes a tombstone`);this.reasonFor.set(e,this.checkReason(s));try{await this.server.ldap.delete(e,r)}finally{this.reasonFor.delete(e)}}async erase(e,{force:t=!1,req:a}={}){const i=await this.read(e);if(!i||!c(i,this.attrs))throw new s(`No tombstone at ${e}`);if(!t){const t=m(p(i,this.attrs.deletedAt)),s=t&&u(t,this.attrs.deletedAtFormat);if(!s)throw new r(`${e} holds no readable deletion date (--twake-lifecycle-deleted-at-attribute); force it to erase now`);if(Date.now()-s.getTime()<1e3*this.eraseMinAge)throw new r(`${e} was deleted less than ${this.eraseMinAge} seconds ago; force it to erase now`)}this.erasing.add(e);try{await this.server.ldap.delete(e,a)}finally{this.erasing.delete(e)}await this.leaveGroups(e),this.logger.info({plugin:this.name,event:"erase",dn:e,force:t})}keeps(e){return!this.erasing.has(e)&&this.matches(e)}assertComposition(){if(!this.server.loadedPlugins.trash)return;const e=String(this.config.trash_watched_bases||"").split(";").map(e=>e.trim()).filter(Boolean),t=this.patterns.filter(t=>{const s=t.source.replace(/\$$/,"");return 0===e.length||e.some(e=>l(s,e)||l(e,s))});if(t.length)throw new Error(`${this.name}: core/ldap/trash watches the branch of ${t.map(e=>e.source).join(", ")}, and would move those entries instead of leaving a tombstone. Leave that branch out of --trash-watched-bases, or load one of the two plugins only`)}async replace(e,t,s){const r=await this.groupsOf(e),a=this.config.group_dummy_user,i=new Set(a?await this.groupsOf(a):[]);await this.erase(e,{force:!0,req:s});const o=s.res;if(!o)return;const n=o.end.bind(o);o.end=(...s)=>{o.end=n;const a=n(...s);return this.restore(e,t,r,i).catch(t=>this.logger.error({plugin:this.name,event:"restore",dn:e,error:String(t)})),a}}async restore(e,t,s,r){if(await this.read(e))return;const a=Object.fromEntries(Object.entries(t).filter(([e,t])=>"dn"!==e&&!(Array.isArray(t)&&0===t.length)));await this.server.ldap.add(e,a);const i=this.config.group_dummy_user;for(const t of s)await this.server.ldap.modify(t,{add:{member:e}}),i&&!r.has(t)&&await this.server.ldap.modify(t,{delete:{member:i}}).catch(e=>{if(16!==o(e))throw e});this.logger.warn({plugin:this.name,event:"restore",dn:e})}matches(e){return this.patterns.some(t=>t.test(e))}isScim(e){const t=e?.originalUrl??e?.url;return"string"==typeof t&&t.startsWith(this.scimPrefix)}checkReason(e){if(!(this.reasons.length?this.reasons.includes(e):g.test(e)))throw new t(`Unknown deletion reason "${e}"${this.reasons.length?`; known: ${this.reasons.join(", ")}`:""}`);return e}reasonOf(e,t){const s=this.reasonFor.get(e);if(s)return s;const r=t?.headers?.[this.reasonHeader],a=Array.isArray(r)?r[0]:r;return a?this.checkReason(a):this.defaultReason}async read(e){try{return(await this.server.ldap.search({paged:!1,scope:"base",attributes:["*",this.attrs.lock]},e)).searchEntries[0]}catch(e){if(32===o(e))return;throw e}}async writeTombstone(e,t,s,r){const{attrs:a}=this;if(c(t,a))return d(this.server.hooks.twakedeletionreplay,e,t,i(r)),void this.logger.info({plugin:this.name,event:"replay",dn:e});const o={[a.deleted]:a.deletedValue,[a.lock]:a.lockValue};a.deletedAt&&(o[a.deletedAt]=w(new Date,a.deletedAtFormat)),a.reason&&(o[a.reason]=s);const n=this.clearAttributes.filter(e=>void 0!==p(t,e));await this.server.ldap.modify(e,n.length?{replace:o,delete:n}:{replace:o},void 0,{context:i(r)}),this.logger.info({plugin:this.name,event:"tombstone",dn:e,reason:s})}async groupsOf(e){const t=[];for(const s of this.groupBases.filter(Boolean))try{const r=await this.server.ldap.search({paged:!1,scope:"sub",filter:`(member=${n(e)})`,attributes:["dn"]},s);t.push(...r.searchEntries.map(e=>e.dn))}catch(e){if(32!==o(e))throw e}return t}async leaveGroups(e){for(const t of await this.groupsOf(e))await this.leaveGroup(t,e)}async leaveGroup(e,t){const s=this.config.group_dummy_user;let r=!1;for(let a=0;;a++)try{return void await this.server.ldap.modify(e,r?{add:{member:s},delete:{member:t}}:{delete:{member:t}})}catch(e){const t=o(e);if(16===t)return;if(a>=2)throw e;if(65===t&&s)r=!0;else{if(20!==t)throw e;r=!1}}}}export{TwakeTombstone as default};
@@ -52,6 +52,8 @@ export declare class DM {
52
52
  */
53
53
  private _authState;
54
54
  private _errorMiddlewareSetup;
55
+ /** The parsers plugins mount ahead of the global ones, see registerBodyParser */
56
+ private readonly bodyParsers;
55
57
  /**
56
58
  * Whether the plugins of the configuration are loaded and their
57
59
  * composition checked. A plugin registered after that — a server
@@ -111,6 +113,20 @@ export declare class DM {
111
113
  * @param plugin plugin whose `authenticate` guards the paths it claims
112
114
  */
113
115
  registerAuthenticator(plugin: AuthBase): void;
116
+ /**
117
+ * Parse the bodies under `path` with `parser`, ahead of the global JSON and
118
+ * form parsers, which then leave them alone: for another media type or
119
+ * size limit there. Authentication runs before the routes, so a parser
120
+ * mounted by a route would come too late for core/auth/hmac.
121
+ *
122
+ * `jsonBodyParser` builds a JSON one that keeps the bytes it reads; any
123
+ * other must pass `keepRawBody` as `verify`: core/auth/hmac refuses a body
124
+ * whose bytes were not kept.
125
+ *
126
+ * @param path the prefix whose bodies `parser` reads
127
+ * @param parser a body-parser middleware
128
+ */
129
+ registerBodyParser(path: string, parser: express.RequestHandler): void;
114
130
  /**
115
131
  * Pick the authentication a request must satisfy, and run it.
116
132
  *
@@ -229,6 +229,14 @@ export interface Config {
229
229
  twake_group_created_at_attribute?: string;
230
230
  twake_group_max_page_limit?: number;
231
231
  twake_group_member_fields?: Record<string, string>;
232
+ twake_space_base?: string;
233
+ twake_space_class?: string[];
234
+ twake_space_display_name_attribute?: string;
235
+ twake_space_admin_attribute?: string;
236
+ twake_space_editor_attribute?: string;
237
+ twake_space_viewer_attribute?: string;
238
+ twake_space_user_role_attribute?: string;
239
+ twake_space_exchange?: string;
232
240
  twake_instance_dn?: string[];
233
241
  twake_instance_skip_attribute?: string;
234
242
  twake_instance_skip_value?: string;
@@ -262,7 +270,6 @@ export interface Config {
262
270
  twake_instance_organization_domain_attribute?: string;
263
271
  twake_instance_organization_name_attribute?: string;
264
272
  twake_instance_organization_fqdn_attribute?: string;
265
- twake_instance_organization_account?: string;
266
273
  twake_lifecycle_reason_attribute?: string;
267
274
  twake_tombstone_dn?: string[];
268
275
  twake_tombstone_default_reason?: string;
@@ -55,10 +55,27 @@ export interface Hooks {
55
55
  ldapaddafter?: (args: [string, AttributesList], context?: ChangeContext) => MaybePromise<void>;
56
56
  ldapmodifyrequest?: ChainedHook<[string, ModifyRequest, number, Request?]>;
57
57
  ldapmodifydone?: (args: [string, ModifyRequest, number], context?: ChangeContext) => MaybePromise<void>;
58
+ /**
59
+ * Once a modify is over, whatever became of it, after its `ldapmodifydone`
60
+ * subscribers have returned; with the operation number
61
+ */
62
+ ldapmodifyend?: (op: number) => MaybePromise<void>;
58
63
  ldapdeleterequest?: ChainedHook<[string | string[], Request?]>;
59
64
  ldapdeletedone?: (dn: string | string[], context?: ChangeContext) => MaybePromise<void>;
65
+ /**
66
+ * Once a delete is over, whatever became of it, after its `ldapdeletedone`
67
+ * subscribers have returned; with the operation number its request and
68
+ * done hooks read from `currentOperation()`
69
+ */
70
+ ldapdeleteend?: (op: number) => MaybePromise<void>;
60
71
  ldaprenamerequest?: ChainedHook<[string, string, Request?]>;
61
72
  ldaprenamedone?: (args: [string, string], context?: ChangeContext) => MaybePromise<void>;
73
+ /**
74
+ * Once a rename is over, whatever became of it, after its `ldaprenamedone`
75
+ * subscribers have returned; with the operation number its request and
76
+ * done hooks read from `currentOperation()`
77
+ */
78
+ ldaprenameend?: (op: number) => MaybePromise<void>;
62
79
  /**
63
80
  * Plugins
64
81
  */
@@ -18,6 +18,11 @@ export declare class BadRequestError extends HttpError {
18
18
  export declare class UnauthorizedError extends HttpError {
19
19
  constructor(message?: string);
20
20
  }
21
+ /**
22
+ * What a client is told of an authorization refusal: the message the plugin
23
+ * threw names the branch, which the caller may not know of.
24
+ */
25
+ export declare const AUTHZ_REFUSED = "Token does not have permission on this branch";
21
26
  export declare class ForbiddenError extends HttpError {
22
27
  constructor(message?: string);
23
28
  }
@@ -242,7 +242,9 @@ declare class ldapActions {
242
242
  private wrapPaginatedSearch;
243
243
  add(dn: string, entry: AttributesList, req?: Request): Promise<boolean>;
244
244
  modify(dn: string, changes: ModifyRequest, req?: Request, options?: ModifyOptions): Promise<boolean>;
245
+ private applyModify;
245
246
  rename(dn: string, newRdn: string, req?: Request): Promise<boolean>;
247
+ private applyRename;
246
248
  /**
247
249
  * Move an entry to a new location (different parent)
248
250
  * Uses LDAP modifyDN with full DN to change both RDN and parent
@@ -258,8 +260,17 @@ declare class ldapActions {
258
260
  * @param req - Request behind the move, when there is one
259
261
  */
260
262
  move(dn: string, newDn: string, req?: Request): Promise<boolean>;
261
- delete(dn: string | string[], req?: Request): Promise<boolean>;
263
+ delete(dn: string | string[], req?: Request, options?: Pick<ModifyOptions, 'context'>): Promise<boolean>;
264
+ private applyDelete;
262
265
  private setDn;
266
+ /**
267
+ * Launch the "end" hooks of a write once its "done" hooks have returned,
268
+ * whatever became of it: written, refused by a request hook, taken out of
269
+ * the request by one, or failed in the directory, with its operation
270
+ * number. A plugin keeping state from a request hook to a done one, keyed
271
+ * by `currentOperation()`, drops there what no done hook took.
272
+ */
273
+ private endOperation;
263
274
  opNumber(): number;
264
275
  }
265
276
  export default ldapActions;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Run a write as operation `op`.
3
+ *
4
+ * @param op the operation number
5
+ * @param write the write, its request and "done" hooks included
6
+ * @returns what the write returns
7
+ */
8
+ export declare function runOperation<T>(op: number, write: () => Promise<T>): Promise<T>;
9
+ /**
10
+ * Run something outside any operation, such as a move a request hook makes:
11
+ * its hooks belong to no request.
12
+ *
13
+ * @param fn what to run
14
+ * @returns what it returns
15
+ */
16
+ export declare function outsideOperation<T>(fn: () => T): T;
17
+ /**
18
+ * The operation a hook is called for.
19
+ *
20
+ * Inside the request and "done" hooks of a modify, delete or rename, it is
21
+ * that write. Elsewhere it is whatever encloses the call: an add or a search
22
+ * made from a delete's request hook (the trash, a tombstone) sees the
23
+ * delete's number, and the "end" hooks run in their caller's context, so an
24
+ * end subscriber must use the number it is given, not this.
25
+ *
26
+ * @returns the number of the modify, delete or rename the call runs inside,
27
+ * or undefined outside any
28
+ */
29
+ export declare function currentOperation(): number | undefined;
@@ -5,12 +5,15 @@
5
5
  */
6
6
  import type { Config } from '../config/args';
7
7
  export type ConfigTemplate = ConfigEntry[];
8
+ export type ArrayKind = 'identifiers' | 'dns' | 'phrases';
8
9
  export type ConfigEntry = [
9
10
  string,
10
11
  string,
11
12
  (string | string[] | boolean | number | Record<string, any>),
12
13
  ('string' | 'number' | 'boolean' | 'array' | 'json' | null | undefined)?,
13
- (string | null | undefined)?
14
+ (string | null | undefined)?,
15
+ ArrayKind?,
16
+ boolean?
14
17
  ];
15
18
  export declare class ConfigParser {
16
19
  private config;
@@ -1,21 +1,40 @@
1
1
  /**
2
- * The bytes of a request body, as the global parsers read them
2
+ * The bytes of a request body, as the parsers ahead of the routes read them
3
3
  * @module lib/rawBody
4
4
  */
5
5
  import type { IncomingMessage } from 'http';
6
+ import bodyParser from 'body-parser';
7
+ import type { RequestHandler } from 'express';
8
+ /** A body as such a parser read it */
9
+ export interface RawBody {
10
+ /** The bytes received, once Content-Encoding is removed */
11
+ bytes: Buffer;
12
+ /** The charset the parser decodes them with, lowercased */
13
+ encoding: string;
14
+ }
6
15
  /**
7
- * body-parser `verify` callback keeping the bytes it read, for
8
- * core/auth/hmac to hash what the client signed
16
+ * body-parser `verify` callback keeping the bytes it read, and the charset it
17
+ * decodes them with, for core/auth/hmac to hash what the client signed
9
18
  *
10
19
  * @param req the request
11
20
  * @param _res the response
12
- * @param buf the body read
21
+ * @param bytes the body read
22
+ * @param encoding the charset body-parser decodes it with: its own reading
23
+ * of the Content-Type, or its default
13
24
  */
14
- export declare function keepRawBody(req: IncomingMessage, _res: unknown, buf: Buffer): void;
25
+ export declare function keepRawBody(req: IncomingMessage, _res: unknown, bytes: Buffer, encoding: string): void;
15
26
  /**
16
- * The bytes a global parser read from a request body.
27
+ * The body a parser kept from a request, with `keepRawBody`.
17
28
  *
18
29
  * @param req the request
19
- * @returns them, or undefined when no global parser read the body
30
+ * @returns it, or undefined when no parser kept it
20
31
  */
21
- export declare function rawBodyOf(req: IncomingMessage): Buffer | undefined;
32
+ export declare function rawBodyOf(req: IncomingMessage): RawBody | undefined;
33
+ /**
34
+ * A JSON parser that keeps the bytes it reads, for `DM.registerBodyParser`:
35
+ * core/auth/hmac refuses a body whose bytes were not kept
36
+ *
37
+ * @param options body-parser JSON options
38
+ * @returns the parser
39
+ */
40
+ export declare function jsonBodyParser(options?: Omit<bodyParser.OptionsJson, 'verify'>): RequestHandler;
@@ -42,6 +42,12 @@ export declare const hookOwner: (fn: Function) => {
42
42
  plugin: string;
43
43
  hook: string;
44
44
  } | undefined;
45
+ /**
46
+ * Whether an error is an authorization plugin's refusal, by the marker its
47
+ * message carries: a plain `Error` wrapping it keeps the message only. Its
48
+ * message names the branch, so the client is told `AUTHZ_REFUSED` instead.
49
+ */
50
+ export declare const isAuthzRefusal: (err: unknown) => boolean;
45
51
  export declare const launchHooksChained: <T>(hooks: Function[] | undefined, args: T) => Promise<T>;
46
52
  export declare const transformSchemas: (schemas: string | Buffer, config: Config) => string;
47
53
  /**
@@ -17,7 +17,7 @@ export default class AuthHmac extends AuthBase {
17
17
  /**
18
18
  * Calculate SHA256 hash of request body
19
19
  * Returns empty string for GET/DELETE/HEAD methods, and undefined for a
20
- * body no global parser read, which cannot be checked
20
+ * body no parser kept, which cannot be checked
21
21
  */
22
22
  private calculateBodyHash;
23
23
  /**
@@ -24,8 +24,11 @@ declare class OnLdapChange extends DmPlugin {
24
24
  name: string;
25
25
  roles: Role[];
26
26
  stack: Record<number, Entry>;
27
- pendingDeletions: Map<string, Entry>;
28
- pendingRenames: Map<string, Entry>;
27
+ pendingDeletions: Map<number, Map<string, Entry>>;
28
+ pendingRenames: Map<number, {
29
+ dn: string;
30
+ entry: Entry;
31
+ }>;
29
32
  hooks: Hooks;
30
33
  read(dn: string): Promise<Entry | undefined>;
31
34
  publish(dn: string, before: Entry | null, after: Entry | null, context?: ChangeContext): void;
@@ -1,7 +1,8 @@
1
- import type { Express } from 'express';
1
+ import type { Express, RequestHandler } from 'express';
2
2
  import DmPlugin, { type Role } from '../../abstract/plugin';
3
3
  import type { DM } from '../../bin';
4
4
  import type ldapActions from '../../lib/ldapActions';
5
+ import type { Config } from '../../config/args';
5
6
  import { writeScimErrorFromException } from './errors';
6
7
  /**
7
8
  * Reusable SCIM 2.0 schemas surfaced by this plugin.
@@ -392,6 +393,15 @@ import { writeScimErrorFromException } from './errors';
392
393
  * resourceType: { type: string, example: Schema }
393
394
  * location: { type: string }
394
395
  */
396
+ /**
397
+ * The parser core/scim registers under its prefix: application/json too, so
398
+ * that a SCIM request sent as such gets the SCIM limit rather than the global
399
+ * parser's 100kb
400
+ *
401
+ * @param config the configuration holding the SCIM limit
402
+ * @returns the parser
403
+ */
404
+ export declare function scimBodyParser(config: Config): RequestHandler;
395
405
  export default class Scim extends DmPlugin {
396
406
  name: string;
397
407
  roles: Role[];