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.
- package/dist/bin/index.js +1 -1
- package/dist/config/args.js +1 -1
- package/dist/lib/errors.js +1 -1
- package/dist/lib/expressFormatedResponses.js +1 -1
- package/dist/lib/ldapActions.js +1 -1
- package/dist/lib/parseConfig.js +1 -1
- package/dist/lib/utils.js +1 -1
- package/dist/plugins/auth/hmac.js +1 -1
- package/dist/plugins/auth/totp.js +1 -1
- package/dist/plugins/scim/errors.js +1 -1
- package/dist/plugins/twake/groups.js +1 -1
- package/dist/plugins/twake/lifecycleEvents.js +1 -1
- package/dist/plugins/twake/spaces.js +1 -0
- package/dist/plugins/twake/tombstone.js +1 -1
- package/dist/src/config/args.d.ts +8 -0
- package/dist/src/lib/errors.d.ts +5 -0
- package/dist/src/lib/ldapActions.d.ts +1 -1
- package/dist/src/lib/parseConfig.d.ts +4 -1
- package/dist/src/lib/utils.d.ts +6 -0
- package/dist/src/plugins/twake/groups.d.ts +72 -12
- package/dist/src/plugins/twake/spaces.d.ts +186 -0
- package/dist/src/plugins/twake/tombstone.d.ts +5 -0
- package/docs/usage/configuration.md +83 -3
- package/docs/usage/plugins/integrations/README.md +6 -0
- package/docs/usage/plugins/integrations/groups.md +12 -5
- package/docs/usage/plugins/integrations/lifecycle-events.md +3 -1
- package/docs/usage/plugins/integrations/spaces.md +221 -0
- package/docs/usage/plugins/ldap/raw.md +1 -1
- package/docs/usage/upgrading.md +97 -0
- package/package.json +6 -1
|
@@ -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(
|
|
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;
|
package/dist/src/lib/errors.d.ts
CHANGED
|
@@ -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;
|
package/dist/src/lib/utils.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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
|
-
|
|
67
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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
|
-
|
|
85
|
-
|
|
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
|
|
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
|
-
|
|
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
|
```
|