ldap-rest 0.14.0 → 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.
@@ -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};
@@ -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;
@@ -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
  }
@@ -260,7 +260,7 @@ declare class ldapActions {
260
260
  * @param req - Request behind the move, when there is one
261
261
  */
262
262
  move(dn: string, newDn: string, req?: Request): Promise<boolean>;
263
- delete(dn: string | string[], req?: Request): Promise<boolean>;
263
+ delete(dn: string | string[], req?: Request, options?: Pick<ModifyOptions, 'context'>): Promise<boolean>;
264
264
  private applyDelete;
265
265
  private setDn;
266
266
  /**
@@ -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;
@@ -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
  /**
@@ -1,12 +1,55 @@
1
- import type { Express } from 'express';
1
+ import type { Express, Request, Response } from 'express';
2
2
  import type { DM } from '../../bin';
3
3
  import type { Schema } from '../../config/schema';
4
4
  import type { Hooks } from '../../hooks';
5
- import type { AttributeValue } from '../../lib/ldapActions';
5
+ import type { AttributesList, AttributeValue } from '../../lib/ldapActions';
6
6
  import LdapGroups from '../ldap/groups';
7
7
  import { type LifecycleAttributes } from './lifecycleAttributes';
8
+ /**
9
+ * An attribute's first value, whatever case its name is configured in: the
10
+ * directory answers it under its schema's spelling.
11
+ */
12
+ export declare const read: (entry: AttributesList, attribute: string) => string | undefined;
13
+ export declare const ORG = "{org}";
14
+ /** A refusal the routes answer as `{ error, code }`. */
15
+ export declare class RouteError extends Error {
16
+ readonly status: number;
17
+ readonly code: string;
18
+ constructor(status: number, message: string, code: string);
19
+ }
20
+ export declare const notFound: (what: string) => RouteError;
21
+ export declare const invalid: (message: string, code?: string) => RouteError;
22
+ export declare const NAME_RULE = "name must be a non-blank string of at most 256 characters with no control characters";
23
+ export declare function isDisplayName(name: unknown): name is string;
24
+ /** A request without a JSON object body leaves `req.body` undefined. */
25
+ export declare function bodyOf(req: Request): Record<string, unknown>;
26
+ export interface Page {
27
+ page: number;
28
+ limit: number;
29
+ offset: number;
30
+ search?: string;
31
+ sortBy?: string;
32
+ desc: boolean;
33
+ }
8
34
  /** The values of an attribute, whatever shape the directory gave them. */
9
35
  export declare function values(value: AttributeValue | undefined): string[];
36
+ /** A DN spelled one way, to compare two spellings of it. */
37
+ export declare function dnKey(dn: string): string;
38
+ export declare function parentOf(dn: string): string;
39
+ /** The RDN value of a DN, unescaped, or the DN itself if it has none. */
40
+ export declare function rdnValue(dn: string): string;
41
+ /**
42
+ * The entries one level under a DN pattern holding `{org}`, the organization
43
+ * captured as one RDN value: `(?:\\.|[^,])+` keeps an escaped comma inside it.
44
+ */
45
+ export declare function branchPattern(base: string): RegExp;
46
+ /**
47
+ * The organization a rename moves an entry into, under a branch pattern, if
48
+ * it changes the entry's parent: what it holds comes along unchecked.
49
+ */
50
+ export declare function movedInto(pattern: RegExp, dn: string, newDn: string): string | undefined;
51
+ /** The organization of an entry one level under a branch pattern. */
52
+ export declare function organizationIn(pattern: RegExp, dn: string): string | undefined;
10
53
  export default class TwakeGroups extends LdapGroups {
11
54
  name: string;
12
55
  protected readonly groupBase: string;
@@ -17,7 +60,7 @@ export default class TwakeGroups extends LdapGroups {
17
60
  private readonly displayName;
18
61
  private readonly color;
19
62
  private readonly createdAt;
20
- private readonly maxPage;
63
+ readonly maxPage: number;
21
64
  private readonly memberFields;
22
65
  constructor(server: DM);
23
66
  /**
@@ -47,27 +90,36 @@ export default class TwakeGroups extends LdapGroups {
47
90
  * within its own organization only. The placeholder belongs to none.
48
91
  */
49
92
  private checkMembers;
93
+ /** A member that is not a user of the organization, if any. */
94
+ private foreign;
50
95
  /**
51
96
  * A tombstone keeps its memberships until it is erased, and is hidden from
52
97
  * them, so every API reads the same member list.
53
98
  */
54
99
  private hideTombstones;
55
- private tombstonesOf;
100
+ /**
101
+ * Read without a request: hiding a tombstone is integrity, not data, and
102
+ * must not depend on what the caller may see.
103
+ */
104
+ tombstonesOf(org: string): Promise<string[]>;
56
105
  /**
57
106
  * Only the organization routes: the flat `/v1/ldap/groups` of
58
107
  * core/ldap/groups would read one base that holds no group here.
59
108
  */
60
109
  api(app: Express): void;
61
- private groupDn;
62
- private get userAttribute();
110
+ /** A route of the organization `:id`; see `RouteError` for refusals. */
111
+ organizationRoute(context: string, handler: (req: Request, res: Response, org: string) => Promise<void>, plugin?: string): (req: Request, res: Response) => Promise<void>;
112
+ groupDn(org: string, id: string): string;
113
+ get userAttribute(): string;
114
+ userDn(org: string, username: string): string;
63
115
  /** Every route answers 404 for a missing organization, 410 for a deleted one. */
64
116
  private checkOrganization;
65
117
  /** Page, search and sort of a list route, or a refusal of them. */
66
- private page;
67
- private sorted;
118
+ page(req: Request, sortable: string[]): Page;
119
+ sorted<T extends Record<string, unknown>>(items: T[], sortBy: string | undefined, desc: boolean): T[];
68
120
  /** A group as the routes answer it: members named by their RDN value. */
69
- private groupOf;
70
- private groups;
121
+ groupOf(org: string, entry: AttributesList): Record<string, unknown>;
122
+ groups(org: string, filter: string, attributes?: string[], req?: Request): Promise<AttributesList[]>;
71
123
  private readGroup;
72
124
  /** The group of that display name in the organization, if any. */
73
125
  private named;
@@ -77,12 +129,20 @@ export default class TwakeGroups extends LdapGroups {
77
129
  private createRoute;
78
130
  private updateRoute;
79
131
  private membersRoute;
132
+ /**
133
+ * A page of members' public profiles, each with what `extra` adds for its
134
+ * username.
135
+ */
136
+ memberPage(p: Page, org: string, names: string[], extra?: (username: string) => Record<string, unknown>, req?: Request): Promise<{
137
+ members: Record<string, unknown>[];
138
+ pagination: Record<string, number | boolean>;
139
+ }>;
80
140
  /**
81
141
  * Users of the organization by name, read in pages of the size limit. A
82
142
  * tombstone is no user: it cannot be added, as it would join hidden.
83
143
  */
84
- private users;
85
- private profiles;
144
+ users(org: string, usernames: string[], attributes: string[], req?: Request): Promise<AttributesList[]>;
145
+ profiles(org: string, usernames: string[], req?: Request): Promise<Map<string, Record<string, unknown>>>;
86
146
  private addMembersRoute;
87
147
  private dropPlaceholder;
88
148
  private removeMemberRoute;
@@ -0,0 +1,186 @@
1
+ import type { Express } from 'express';
2
+ import DmPlugin, { type Role } from '../../abstract/plugin';
3
+ import type { DM } from '../../bin';
4
+ import type { Hooks } from '../../hooks';
5
+ /** From the weakest to the strongest. */
6
+ export declare const SPACE_ROLES: readonly ["viewer", "editor", "admin"];
7
+ export type SpaceRole = (typeof SPACE_ROLES)[number];
8
+ export type Space = {
9
+ id: string;
10
+ name: string;
11
+ organizationId: string;
12
+ members: {
13
+ username: string;
14
+ role: SpaceRole;
15
+ }[];
16
+ groups: {
17
+ id: string;
18
+ role: SpaceRole;
19
+ }[];
20
+ };
21
+ export default class TwakeSpaces extends DmPlugin {
22
+ name: string;
23
+ roles: Role[];
24
+ dependencies: Record<string, string>;
25
+ private readonly spaceBase;
26
+ private readonly spacePattern;
27
+ private readonly userPattern;
28
+ private readonly lifecycle;
29
+ private readonly displayName;
30
+ readonly roleAttributes: Record<SpaceRole, string>;
31
+ private readonly userRole;
32
+ private readonly exchange;
33
+ /**
34
+ * Changes are followed one at a time, each from the directory as it is
35
+ * then, so the order they are followed in does not matter.
36
+ */
37
+ private following;
38
+ private publishing;
39
+ /**
40
+ * The spaces of a group being deleted, by DN key, read before refint takes
41
+ * it out of them, until its deletion is followed. `op` is the delete
42
+ * still running, `owed` the unlinks space writes left to it meanwhile.
43
+ */
44
+ private readonly unlinking;
45
+ /**
46
+ * The groups a deletion announced unlinked, by space DN key, until a write
47
+ * of the space takes their DN out of it, or finds it gone already.
48
+ */
49
+ private readonly announced;
50
+ /**
51
+ * The spaces a user being deleted is an admin of, by operation and DN,
52
+ * read before refint takes the user out of them.
53
+ */
54
+ private readonly handing;
55
+ constructor(server: DM);
56
+ /** Whether user roles are kept or events published. */
57
+ private get follows();
58
+ private get groups();
59
+ private get tombstone();
60
+ hooks: Hooks;
61
+ /**
62
+ * core/rabbitmq connects lazily and hands back no client when it cannot:
63
+ * every event would then be lost, so the server does not start.
64
+ *
65
+ * core/ldap/trash moves a deleted group or space away instead of deleting
66
+ * it: nothing follows the move, so users would keep the roles it gave,
67
+ * and no event would say it went.
68
+ */
69
+ assertComposition(): Promise<void>;
70
+ private queue;
71
+ /** The organization a space DN belongs to, if it is a space at all. */
72
+ organizationOf(dn: string): string | undefined;
73
+ spaceBaseOf(org: string): string;
74
+ spaceDn(org: string, id: string): string;
75
+ /** An entry as it is, read for no one, or nothing for one missing. */
76
+ private entry;
77
+ /** A space holds users and groups of its organization, whichever API writes it. */
78
+ private checkHolders;
79
+ /** A holder that is neither a user nor a group of the organization. */
80
+ private foreign;
81
+ api(app: Express): void;
82
+ /** A space as the routes answer it, strongest role first. */
83
+ private spaceOf;
84
+ /**
85
+ * The users and groups an entry holds, strongest role first, each once
86
+ * with its strongest role. A tombstone is hidden.
87
+ */
88
+ private entryOf;
89
+ /** The spaces of the organization matching a filter, or the one of an id. */
90
+ private spaces;
91
+ /**
92
+ * Whoever asks: a tombstone outside the caller's branches would otherwise
93
+ * count as a member, and as an admin.
94
+ */
95
+ private hidden;
96
+ /** A filter for the spaces holding any of these DNs, whatever the role. */
97
+ private holding;
98
+ private space;
99
+ private write;
100
+ /**
101
+ * A change decided on a space read before another request wrote it fails
102
+ * with LDAP 16 or 20: it is decided again on the space as it is now.
103
+ */
104
+ private again;
105
+ /** The live users of the organization by lowercased name, as DNs, or a 404. */
106
+ private liveUsers;
107
+ private get groupName();
108
+ /** Groups of the organization by id, read in pages of the size limit. */
109
+ private orgGroups;
110
+ /**
111
+ * The users a space write or a change of a group's members may have
112
+ * moved, and the events of the write. A deleted group is followed through
113
+ * its members as they were.
114
+ */
115
+ private follow;
116
+ /**
117
+ * The spaces a user is an admin of. Read again when a tombstone is erased:
118
+ * its hand-over may have failed, or predate this plugin, and handing over
119
+ * a space that keeps another admin does nothing.
120
+ */
121
+ private administered;
122
+ /**
123
+ * A space a deleted user was the last admin of goes to its editors, else
124
+ * its viewers; one left with no member is deleted.
125
+ */
126
+ private handOver;
127
+ private handOverOnce;
128
+ /** A group about to be deleted, if it is one linked to spaces. */
129
+ private deletedGroup;
130
+ /**
131
+ * The spaces a deleted group left. One still holding its DN, without
132
+ * refint, remembers the announcement for the write that takes it out.
133
+ */
134
+ private unlinked;
135
+ /**
136
+ * Rewrite each user's roles from the directory as it is now: a change
137
+ * only tells which users to look at, so two changes followed in either
138
+ * order, or late, leave the same roles.
139
+ *
140
+ * The roles each user had come from the values written before, or, with
141
+ * no user role attribute, from the directory with the change undone: two
142
+ * changes followed late may then announce a role twice, or not at all.
143
+ */
144
+ private settle;
145
+ /**
146
+ * A filter for the spaces a user is in, directly or through a group, and
147
+ * their role in one of them. With a group's members undone, as they were.
148
+ */
149
+ private userIn;
150
+ /**
151
+ * The events of a followed change: the space write itself, then each
152
+ * moved user's roles, except in a space created or deleted by it.
153
+ *
154
+ * A role may have moved in a space the change did not touch, by another
155
+ * change not followed yet. Without the role attribute, that change
156
+ * announces it. With it, this follow wrote the value, so that change
157
+ * will find nothing to announce: the role goes out here, without an
158
+ * actor, as this change's is not the one that moved it.
159
+ */
160
+ private announce;
161
+ /** Users as space events name them, by DN key, live users only. */
162
+ private profiles;
163
+ private publishEvent;
164
+ private get rabbitmq();
165
+ private publish;
166
+ /** A user's roles, in the directory as it is or with a change undone. */
167
+ private rolesOf;
168
+ /**
169
+ * Write a user's `<space id>:<role>` values, and answer the roles they
170
+ * held, or nothing for a user gone.
171
+ */
172
+ private writeRoles;
173
+ /** The groups of the organization by lowercased id, as DNs, or a 404. */
174
+ private groupDns;
175
+ /** Each of a space's members or groups holds one role. */
176
+ private add;
177
+ private addOnce;
178
+ /**
179
+ * Move a member or group to another role, or out of the space without
180
+ * one. A space keeps at least one admin among its users.
181
+ */
182
+ private change;
183
+ private changeOnce;
184
+ private listRoute;
185
+ private createRoute;
186
+ }
@@ -53,6 +53,11 @@ export default class TwakeTombstone extends DmPlugin {
53
53
  force?: boolean;
54
54
  req?: Request;
55
55
  }): Promise<void>;
56
+ /**
57
+ * Whether a delete of this DN, asked for now, writes a tombstone instead of
58
+ * reaching the directory: not when it erases one.
59
+ */
60
+ keeps(dn: string): boolean;
56
61
  /**
57
62
  * core/ldap/trash moves a deleted entry away before this plugin, last in
58
63
  * the delete chain, sees it: no tombstone would be written, and an erase
@@ -59,18 +59,37 @@ All CLI options can be set via environment variables with the `DM_` prefix.
59
59
 
60
60
  Some options accept multiple values (e.g., `--plugin`, `--auth-token`, `--ldap-url`). These can be configured in several ways:
61
61
 
62
- **Via CLI - repeat the option:**
62
+ **Via CLI - repeat the option, or give it comma-separated values:**
63
63
 
64
64
  ```bash
65
- ldap-rest --plugin core/auth/token --plugin core/ldap/flatGeneric --plugin core/ldap/groups
65
+ ldap-rest --plugin core/auth/token --plugin core/ldap/flatGeneric,core/ldap/groups
66
66
  ```
67
67
 
68
+ Only options holding identifiers split on commas: `--plugin`, `--mail-domain`,
69
+ `--ldap-url`, the object class options (`--user-class`, `--group-class`,
70
+ `--ldap-organization-class`, `--external-branch-class`,
71
+ `--scim-user-object-class`, `--scim-group-object-class`,
72
+ `--twake-space-class`), the attribute
73
+ options (`--ldap-raw-hidden-attribute`, `--applicative-account-attribute`,
74
+ `--ldap-operational-attribute`, `--twake-tombstone-clear-attributes`),
75
+ `--twake-tombstone-reasons`, `--authz-for`, `--authz-dynamic-bypass` and
76
+ `--trusted-proxy`. Any other
77
+ option takes each value whole, so a DN or a secret keeps its commas.
78
+
68
79
  **Via CLI - use plural form with comma-separated values:**
69
80
 
70
81
  ```bash
71
82
  ldap-rest --plugins core/auth/token,core/ldap/flatGeneric,core/ldap/groups
72
83
  ```
73
84
 
85
+ The plural form splits like the environment variable, see below. Give a
86
+ DN with the repeated singular option, or separate DNs with `;` in the plural
87
+ form:
88
+
89
+ ```bash
90
+ ldap-rest --ldap-raw-base ou=users,dc=example,dc=com --ldap-raw-base ou=groups,dc=example,dc=com
91
+ ```
92
+
74
93
  **Via environment variable - use `;` or `,` as separator:**
75
94
 
76
95
  ```bash
@@ -81,7 +100,49 @@ export DM_PLUGINS="core/auth/token;core/ldap/flatGeneric;core/ldap/groups"
81
100
  export DM_PLUGINS="core/auth/token,core/ldap/flatGeneric,core/ldap/groups"
82
101
  ```
83
102
 
84
- > **Note:** If the value contains a semicolon, it will be used as the separator. Otherwise, commas are used. Whitespace around separators is ignored.
103
+ The split depends on what the option holds. The plural form splits the same
104
+ way:
105
+
106
+ - Options holding identifiers (the list above): a value containing `;` is
107
+ split on `;` and spaces, any other on `,` and spaces. A value starting with
108
+ `;` is split on `;` too.
109
+ - Options holding DNs or DN expressions (`--ldap-raw-base`,
110
+ `--james-mailing-list-branch`, `--twake-tombstone-dn`,
111
+ `--twake-tombstone-group-bases`, `--twake-instance-dn`): split on `;` only,
112
+ never on `,` nor spaces, as a DN holds both.
113
+ `DM_LDAP_RAW_BASE="ou=My Unit,dc=example,dc=com"` is one DN; separate
114
+ several DNs with `;`.
115
+ - `--auth-hmac` (`id:secret:name`) and `--auth-totp` (`secret:name[:digits]`):
116
+ split on `;` if the value contains one, else on `,`, and on newlines (one
117
+ entry per line), never on spaces, so a name keeps its spaces. A list
118
+ separated by spaces is read as one entry: for HMAC the services after the
119
+ first cannot authenticate, and for TOTP the entry is refused. Any entry that
120
+ cannot work stops the server: an HMAC one with an empty id, secret or name or
121
+ fewer than 3 fields, a TOTP one with an empty secret or name, too many
122
+ fields, a secret that is not Base32 or digits that are not an integer from 6
123
+ to 10.
124
+ - Any other option (tokens, paths, rules...): split on `;` if
125
+ the value contains one, else on `,`, and on spaces. `DM_AUTH_TOKENS="t1 t2"`
126
+ and `DM_AUTH_TOKENS="t1,t2"` are two tokens, and padded base64 secrets
127
+ (`YWJj=,ZGVm=`) split on the comma.
128
+
129
+ Empty items are dropped, and each item is trimmed. A variable that is empty,
130
+ only holds whitespace or only holds separators (`;`, `${A:-};${B:-}`), such as
131
+ `${VAR:-}` in a compose file, counts as unset: the default applies, so an
132
+ environment variable cannot give an empty list to an option whose default is
133
+ not empty. The exception is `DM_LDAP_URL`, whose default `ldap://localhost` is
134
+ a guess: an empty value stops the server. The same
135
+ goes for an empty number or JSON value such as `DM_PORT=`. A number option
136
+ takes an integer only: `DM_PORT=abc`, `DM_PORT=1e3`, `--port 80abc` and
137
+ `--port --log-level debug` stop the server, and so does an empty number on the
138
+ command line, as an empty array value does.
139
+ The plural form of an option takes `;` before `,` too: `--auth-tokens 'a;b,c'`
140
+ is `a` and `b,c`.
141
+
142
+ Values given on the command line replace the default, and are added to those
143
+ of the environment variable. An empty value on the command line, such as an
144
+ unset variable expanding to nothing, stops the server: leave the option out
145
+ to keep the default.
85
146
 
86
147
  ## General Options
87
148
 
@@ -541,6 +602,25 @@ Extends `core/ldap/groups`, whose options it reads too.
541
602
 
542
603
  See [groups](plugins/integrations/groups.md).
543
604
 
605
+ #### `core/twake/spaces`
606
+
607
+ Requires `core/twake/groups`, whose options it reads for users, groups and the
608
+ organization entry, and `core/ldap/onChange`; with `--rabbitmq-url`, it
609
+ requires `core/rabbitmq`.
610
+
611
+ | CLI | Env | Default | Description |
612
+ | -------------------------------------- | --------------------------------------- | ------------------ | -------------------------------------------------- |
613
+ | `--twake-space-base` | `DM_TWAKE_SPACE_BASE` | | Space branch of an organization, with `{org}` |
614
+ | `--twake-space-class` | `DM_TWAKE_SPACE_CLASS` | `top,twakeSpace` | Object classes of a space |
615
+ | `--twake-space-display-name-attribute` | `DM_TWAKE_SPACE_DISPLAY_NAME_ATTRIBUTE` | `twakeDisplayName` | Space name attribute |
616
+ | `--twake-space-admin-attribute` | `DM_TWAKE_SPACE_ADMIN_ATTRIBUTE` | `twakeSpaceAdmin` | DNs holding the admin role |
617
+ | `--twake-space-editor-attribute` | `DM_TWAKE_SPACE_EDITOR_ATTRIBUTE` | `twakeSpaceEditor` | DNs holding the editor role |
618
+ | `--twake-space-viewer-attribute` | `DM_TWAKE_SPACE_VIEWER_ATTRIBUTE` | `twakeSpaceViewer` | DNs holding the viewer role |
619
+ | `--twake-space-user-role-attribute` | `DM_TWAKE_SPACE_USER_ROLE_ATTRIBUTE` | | User attribute holding `<space id>:<role>`, if set |
620
+ | `--twake-space-exchange` | `DM_TWAKE_SPACE_EXCHANGE` | `space` | Topic exchange of the space events |
621
+
622
+ See [spaces](plugins/integrations/spaces.md).
623
+
544
624
  ### Utility Plugins
545
625
 
546
626
  #### `core/static`
@@ -19,6 +19,7 @@ Plugins for connecting LDAP-Rest to external systems.
19
19
  | [instances](instances.md) | Workplace instance of each account |
20
20
  | [tombstone](tombstone.md) | Deleted accounts kept as tombstones |
21
21
  | [groups](groups.md) | Organization groups |
22
+ | [spaces](spaces.md) | Organization spaces, with member roles |
22
23
 
23
24
  ## Prerequisites
24
25
 
@@ -47,4 +48,9 @@ core/twake/calendar
47
48
  core/twake/lifecycleEvents
48
49
  ├─ requires: core/ldap/onChange
49
50
  └─ requires: core/rabbitmq
51
+
52
+ core/twake/spaces
53
+ ├─ requires: core/twake/groups
54
+ ├─ requires: core/ldap/onChange
55
+ └─ requires: core/rabbitmq (with RabbitMQ)
50
56
  ```