ldap-rest 0.14.0 → 0.16.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.
@@ -1 +1 @@
1
- import{randomUUID as e}from"node:crypto";import t from"node:fs";import r from"../../abstract/plugin.js";import{extractLdapCode as i}from"../../lib/ldapCodes.js";import{unescapeDnValue as s,isDummyMemberDn as n,parseDn as o,normalizeDn as a}from"../../lib/utils.js";import{DEFAULT_LOCK_ATTRIBUTE as c,DEFAULT_LOCK_VALUE as l}from"../scim/mapping.js";import{lifecycleAttributes as h,isTombstone as u,isLocked as d,valueOf as f,first as m,parseDeletedAt as b}from"./lifecycleAttributes.js";const g=["created","roleChanged","disabled","enabled","updated","memberAdded","memberRemoved","deleted"],p=["$members","$added","$removed"];const isObject=e=>"object"==typeof e&&null!==e&&!Array.isArray(e);function optionalString(e,t,r){if(void 0!==e&&"string"!=typeof e)throw new Error(`${t}: "${r}" must be a string`)}const isStrings=e=>isObject(e)&&Object.values(e).every(e=>"string"==typeof e);function optionalCondition(e,t,r){if(void 0!==e){if(!isStrings(e))throw new Error(`${t}: "${r}" must be an object of strings`);return e}}const y=/^(?:[^$].*|\$(?!now$)[A-Za-z][A-Za-z0-9-]*)$/;function memberList(e){if(!isObject(e))return;const t=Object.entries(e);if(1!==t.length)return;const[r,i]=t[0];return p.includes(r)&&isStrings(i)&&Object.values(i).every(e=>y.test(e))?{list:r,fields:i}:void 0}function optionalPayload(e,t,r){if(void 0===e)return;const i=isObject(e)?Object.entries(e):void 0,s={};for(const[e,t]of i||[]){const r="string"==typeof t?t:memberList(t);if(void 0===r)break;s[e]=r}if(!i||Object.keys(s).length!==i.length)throw new Error(`${t}: "${r}" must be an object of strings, or of ${p.join(", ")} each mapped to an object of member attributes ($attr) or plain values`);return s}function dnKey(e){try{return a(e)}catch{return e.toLowerCase()}}function parseRules(e){if(!e.trim())return[];const r=/^\s*[[{]/.test(e)?e:t.readFileSync(e,"utf8"),i=JSON.parse(r);if(!Array.isArray(i))throw new Error("--twake-lifecycle-rules must be a JSON array of rules");return i.map(e=>{if(!isObject(e))throw new Error("Every lifecycle rule must be an object");if("string"!=typeof e.dn)throw new Error('Every lifecycle rule needs a "dn" pattern');const{dn:t}=e,r=`Lifecycle rule ${t}`;optionalString(e.exchange,r,"exchange");const i=optionalPayload(e.payload,r,"payload");if(void 0!==e.events&&!isObject(e.events))throw new Error(`${r}: "events" must be an object`);const s={};for(const[r,n]of Object.entries(e.events||{})){if(!g.includes(r))throw new Error(`Unknown lifecycle event "${r}"; known: ${g.join(", ")}`);const o=`Lifecycle event "${r}" of ${t}`;s[r]=(Array.isArray(n)?n:[n]).map(t=>{if("string"!=typeof t&&!isObject(t))throw new Error(`${o}: a target is a routing key or an object`);const r="string"==typeof t?{routingKey:t}:t;optionalString(r.routingKey,o,"routingKey"),optionalString(r.exchange,o,"exchange");const s=r.exchange||e.exchange,n=r.routingKey;if(!s||!n)throw new Error(`${o} needs an exchange and a routing key`);return{exchange:s,routingKey:n,payload:optionalPayload(r.payload,o,"payload")||i||{},when:optionalCondition(r.when,o,"when")||{}}})}return{dn:new RegExp(t,"i"),targets:s}})}class TwakeLifecycleEvents extends r{constructor(e){if(super(e),this.name="twakeLifecycleEvents",this.roles=["consistency"],this.dependencies={onLdapChange:"core/ldap/onChange",rabbitmq:"core/rabbitmq"},this.hooks={onLdapEntryChange:(e,t,r,i)=>this.onEntryChange(e,t,r,i),twakedeletionreplay:(e,t,r)=>this.replay(e,t,r)},this.attrs=h(this.config),this.memberAttribute=this.config.twake_lifecycle_member_attribute||"member",this.followedOperationalAttributes=[this.attrs.lock],this.rules=parseRules(this.config.twake_lifecycle_rules||""),0===this.rules.length)this.logger.warn(`${this.name}: --twake-lifecycle-rules is empty, nothing will be published`);else if(!this.config.rabbitmq_url)throw new Error(`${this.name}: --twake-lifecycle-rules needs --rabbitmq-url`)}async assertComposition(){if(0!==this.rules.length&&!await(this.requirePlugin("rabbitmq")?.getRawClient()))throw new Error(`${this.name}: RabbitMQ at --rabbitmq-url cannot be reached`)}afterLoad(){const e=this.server.loadedPlugins.scim?.config;if(!e)return;const t=e.scim_user_lock_attribute?.trim()||c,r=e.scim_user_lock_value?.trim()||l,{lock:i,lockValue:s}=this.attrs;t.toLowerCase()===i.toLowerCase()&&r.toLowerCase()===s.toLowerCase()||this.logger.warn(`${this.name}: SCIM locks an account with ${t}: ${r}, these events read ${i}: ${s}, so a SCIM deactivation publishes no disabled`)}async replay(e,t,r){const i=this.match(e);i&&u(t,this.attrs)&&await this.publish(i.rule,"deleted",{before:t,after:t,dn:i.groups,change:r})}match(e){const t=function(e){return o(e).map(e=>{const t=e.indexOf("=");if(-1===t)return e;const r=e.slice(t+1).trim();return`${e.slice(0,t).trim()}=${r.replace(/\\,/g,"\\2C")}`}).join(",")}(e);for(const e of this.rules){const r=e.dn.exec(t);if(!r)continue;const i={};for(const[e,t]of Object.entries(r.groups||{}))void 0!==t&&(i[e]=s(t));return{rule:e,groups:i}}}async onEntryChange(e,t,r,i){const s=this.match(e);if(!s||t&&u(t,this.attrs))return;const{rule:n}=s,o={before:t||{},after:r||t||{},dn:s.groups,change:i};if(!r)return this.publish(n,"deleted",o);if(u(r,this.attrs))return t?this.publish(n,"deleted",o):void 0;if(!t)return await this.publish(n,"created",o),void(d(r,this.attrs)&&await this.publish(n,"disabled",o));this.attrs.role&&!function(e,t){const set=e=>new Set((void 0===e?[]:Array.isArray(e)?e:[e]).map(e=>e.toString().toLowerCase())),r=set(e),i=set(t);return r.size===i.size&&[...r].every(e=>i.has(e))}(f(t,this.attrs.role),f(r,this.attrs.role))&&await this.publish(n,"roleChanged",o);const a=d(r,this.attrs);a!==d(t,this.attrs)&&await this.publish(n,a?"disabled":"enabled",o),await this.publish(n,"updated",o);const c=this.memberDiff(r,t),l=this.memberDiff(t,r);c.length&&await this.publish(n,"memberAdded",{...o,added:c}),l.length&&await this.publish(n,"memberRemoved",{...o,removed:l})}members(e){const t=f(e,this.memberAttribute);return(void 0===t?[]:Array.isArray(t)?t:[t]).map(e=>e.toString()).filter(e=>!n(e,this.config.group_dummy_user))}memberDiff(e,t){const r=new Set(this.members(t).map(dnKey));return this.members(e).filter(e=>!r.has(dnKey(e)))}async memberPayloads({list:e,fields:t},r){const i="$added"===e?r.added||[]:"$removed"===e?r.removed||[]:this.members(r.after),s=Object.values(t).filter(e=>e.startsWith("$")).map(e=>e.slice(1));this.attrs.deleted&&s.push(this.attrs.deleted);const n=[];for(const e of i){const r=await this.readMember(e,s);if(!r||u(r,this.attrs))continue;const i={};for(const[e,s]of Object.entries(t)){const t=s.startsWith("$")?m(f(r,s.slice(1))):s;void 0!==t&&(i[e]=t)}n.push(i)}return n}async readMember(e,t){try{const{searchEntries:r}=await this.server.ldap.search({paged:!1,scope:"base",attributes:t},e);if(r[0])return r[0]}catch(e){if(32!==i(e))throw e}const[r]=o(e),n=r?.indexOf("=")??-1;if(-1!==n)return{[r.slice(0,n).trim()]:s(r.slice(n+1).trim())}}resolve(e,t){if(!e.startsWith("$"))return e;if("$now"===e)return(new Date).toISOString();let r=e.slice(1);const i=r.endsWith("|domain");let s;i&&(r=r.slice(0,-7));let n=r;if(r.startsWith("dn."))s=t.dn[r.slice(3)],n="";else if(r.startsWith("context."))s=t.change[r.slice(8)],n="";else if(r.startsWith("previous."))n=r.slice(9),s=m(f(t.before,n));else if(r.startsWith("changed.")){n=r.slice(8);const e=f(t.after,n);(function(e,t){const list=e=>(void 0===e?[]:Array.isArray(e)?e:[e]).map(e=>e.toString()).sort();return list(e).join("\0")===list(t).join("\0")})(f(t.before,n),e)||(s=m(e)??"")}else s=m(f(t.after,n));return void 0!==s&&this.attrs.deletedAt&&n.toLowerCase()===this.attrs.deletedAt.toLowerCase()&&(s=b(s,this.attrs.deletedAtFormat)?.toISOString()),i&&(s=s?.split("@")[1]),s}async publish(t,r,i){const s=t.targets[r];if(!s?.length)return;const n=this.requirePlugin("rabbitmq");if(n)for(const t of s){if(!Object.entries(t.when).every(([e,t])=>{const r=this.resolve(e,i);return t.startsWith("!")?r!==t.slice(1):r===t}))continue;const s={};let o=!1,a=!1,c=!1;try{for(const[e,r]of Object.entries(t.payload)){if("string"!=typeof r){const t=await this.memberPayloads(r,i);t.length||"$members"===r.list||(c=!0),s[e]=t;continue}const t=this.resolve(r,i);r.startsWith("$changed.")&&(o=!0,void 0!==t&&(a=!0)),void 0!==t&&(s[e]=t)}}catch(e){this.logger.error({plugin:this.name,event:r,routingKey:t.routingKey,result:"members unreadable",error:String(e)});continue}if(c||o&&!a)continue;const l=e(),h={plugin:this.name,event:r,exchange:t.exchange,routingKey:t.routingKey,messageId:l};if(await n.getRawClient())try{await n.publish(t.exchange,t.routingKey,s,{messageId:l}),this.logger.info({...h,result:"published"})}catch(e){this.logger.error({...h,result:"error",error:String(e)})}else this.logger.error({...h,result:"no broker"})}}}export{g as LIFECYCLE_EVENTS,TwakeLifecycleEvents as default,parseRules};
1
+ import{randomUUID as e}from"node:crypto";import t from"node:fs";import r from"../../abstract/plugin.js";import{extractLdapCode as s}from"../../lib/ldapCodes.js";import{unescapeDnValue as i,isDummyMemberDn as n,parseDn as o,normalizeDn as a}from"../../lib/utils.js";import{DEFAULT_LOCK_ATTRIBUTE as c,DEFAULT_LOCK_VALUE as l}from"../scim/mapping.js";import{lifecycleAttributes as h,isTombstone as u,isLocked as d,valueOf as f,first as m,parseDeletedAt as b}from"./lifecycleAttributes.js";const g=["created","roleChanged","disabled","enabled","updated","memberAdded","memberRemoved","deleted"],p=["$members","$added","$removed"];const isObject=e=>"object"==typeof e&&null!==e&&!Array.isArray(e);function optionalString(e,t,r){if(void 0!==e&&"string"!=typeof e)throw new Error(`${t}: "${r}" must be a string`)}const isStrings=e=>isObject(e)&&Object.values(e).every(e=>"string"==typeof e);function optionalCondition(e,t,r){if(void 0!==e){if(!isStrings(e))throw new Error(`${t}: "${r}" must be an object of strings`);return e}}const y=/^(?:[^$].*|\$(?!now$)[A-Za-z][A-Za-z0-9-]*)$/;function memberList(e){if(!isObject(e))return;const t=Object.entries(e);if(1!==t.length)return;const[r,s]=t[0];return p.includes(r)&&isStrings(s)&&Object.values(s).every(e=>y.test(e))?{list:r,fields:s}:void 0}function optionalPayload(e,t,r){if(void 0===e)return;const s=isObject(e)?Object.entries(e):void 0,i={};for(const[e,t]of s||[]){const r="string"==typeof t?t:memberList(t);if(void 0===r)break;i[e]=r}if(!s||Object.keys(i).length!==s.length)throw new Error(`${t}: "${r}" must be an object of strings, or of ${p.join(", ")} each mapped to an object of member attributes ($attr) or plain values`);return i}function dnKey(e){try{return a(e)}catch{return e.toLowerCase()}}function ruleAttributes(e){const t=/^\$(?:previous\.|changed\.)?([A-Za-z][A-Za-z0-9-]*)(?:\|domain)?$/;return e.flatMap(e=>Object.values(e.targets).flat()).flatMap(e=>[...Object.values(e.payload),...Object.keys(e.when)]).flatMap(e=>"string"==typeof e&&t.exec(e)?.[1]||[]).filter(e=>"now"!==e)}function parseRules(e){if(!e.trim())return[];const r=/^\s*[[{]/.test(e)?e:t.readFileSync(e,"utf8"),s=JSON.parse(r);if(!Array.isArray(s))throw new Error("--twake-lifecycle-rules must be a JSON array of rules");return s.map(e=>{if(!isObject(e))throw new Error("Every lifecycle rule must be an object");if("string"!=typeof e.dn)throw new Error('Every lifecycle rule needs a "dn" pattern');const{dn:t}=e,r=`Lifecycle rule ${t}`;optionalString(e.exchange,r,"exchange");const s=optionalPayload(e.payload,r,"payload");if(void 0!==e.events&&!isObject(e.events))throw new Error(`${r}: "events" must be an object`);const i={};for(const[r,n]of Object.entries(e.events||{})){if(!g.includes(r))throw new Error(`Unknown lifecycle event "${r}"; known: ${g.join(", ")}`);const o=`Lifecycle event "${r}" of ${t}`;i[r]=(Array.isArray(n)?n:[n]).map(t=>{if("string"!=typeof t&&!isObject(t))throw new Error(`${o}: a target is a routing key or an object`);const r="string"==typeof t?{routingKey:t}:t;optionalString(r.routingKey,o,"routingKey"),optionalString(r.exchange,o,"exchange");const i=r.exchange||e.exchange,n=r.routingKey;if(!i||!n)throw new Error(`${o} needs an exchange and a routing key`);return{exchange:i,routingKey:n,payload:optionalPayload(r.payload,o,"payload")||s||{},when:optionalCondition(r.when,o,"when")||{}}})}return{dn:new RegExp(t,"i"),targets:i}})}class TwakeLifecycleEvents extends r{constructor(e){if(super(e),this.name="twakeLifecycleEvents",this.roles=["consistency"],this.dependencies={onLdapChange:"core/ldap/onChange",rabbitmq:"core/rabbitmq"},this.hooks={onLdapEntryChange:(e,t,r,s)=>this.onEntryChange(e,t,r,s),twakedeletionreplay:(e,t,r)=>this.replay(e,t,r)},this.attrs=h(this.config),this.memberAttribute=this.config.twake_lifecycle_member_attribute||"member",this.rules=parseRules(this.config.twake_lifecycle_rules||""),this.followedOperationalAttributes=[this.attrs.lock,...ruleAttributes(this.rules)],0===this.rules.length)this.logger.warn(`${this.name}: --twake-lifecycle-rules is empty, nothing will be published`);else if(!this.config.rabbitmq_url)throw new Error(`${this.name}: --twake-lifecycle-rules needs --rabbitmq-url`)}async assertComposition(){if(0!==this.rules.length&&!await(this.requirePlugin("rabbitmq")?.getRawClient()))throw new Error(`${this.name}: RabbitMQ at --rabbitmq-url cannot be reached`)}afterLoad(){const e=this.server.loadedPlugins.scim?.config;if(!e)return;const t=e.scim_user_lock_attribute?.trim()||c,r=e.scim_user_lock_value?.trim()||l,{lock:s,lockValue:i}=this.attrs;t.toLowerCase()===s.toLowerCase()&&r.toLowerCase()===i.toLowerCase()||this.logger.warn(`${this.name}: SCIM locks an account with ${t}: ${r}, these events read ${s}: ${i}, so a SCIM deactivation publishes no disabled`)}async replay(e,t,r){const s=this.match(e);s&&u(t,this.attrs)&&await this.publish(s.rule,"deleted",{before:t,after:t,dn:s.groups,change:r})}match(e){const t=function(e){return o(e).map(e=>{const t=e.indexOf("=");if(-1===t)return e;const r=e.slice(t+1).trim();return`${e.slice(0,t).trim()}=${r.replace(/\\,/g,"\\2C")}`}).join(",")}(e);for(const e of this.rules){const r=e.dn.exec(t);if(!r)continue;const s={};for(const[e,t]of Object.entries(r.groups||{}))void 0!==t&&(s[e]=i(t));return{rule:e,groups:s}}}async onEntryChange(e,t,r,s){const i=this.match(e);if(!i||t&&u(t,this.attrs))return;const{rule:n}=i,o={before:t||{},after:r||t||{},dn:i.groups,change:s};if(!r)return this.publish(n,"deleted",o);if(u(r,this.attrs))return t?this.publish(n,"deleted",o):void 0;if(!t)return await this.publish(n,"created",o),void(d(r,this.attrs)&&await this.publish(n,"disabled",o));this.attrs.role&&!function(e,t){const set=e=>new Set((void 0===e?[]:Array.isArray(e)?e:[e]).map(e=>e.toString().toLowerCase())),r=set(e),s=set(t);return r.size===s.size&&[...r].every(e=>s.has(e))}(f(t,this.attrs.role),f(r,this.attrs.role))&&await this.publish(n,"roleChanged",o);const a=d(r,this.attrs);a!==d(t,this.attrs)&&await this.publish(n,a?"disabled":"enabled",o),await this.publish(n,"updated",o);const c=this.memberDiff(r,t),l=this.memberDiff(t,r);c.length&&await this.publish(n,"memberAdded",{...o,added:c}),l.length&&await this.publish(n,"memberRemoved",{...o,removed:l})}members(e){const t=f(e,this.memberAttribute);return(void 0===t?[]:Array.isArray(t)?t:[t]).map(e=>e.toString()).filter(e=>!n(e,this.config.group_dummy_user))}memberDiff(e,t){const r=new Set(this.members(t).map(dnKey));return this.members(e).filter(e=>!r.has(dnKey(e)))}async memberPayloads({list:e,fields:t},r){const s="$added"===e?r.added||[]:"$removed"===e?r.removed||[]:this.members(r.after),i=Object.values(t).filter(e=>e.startsWith("$")).map(e=>e.slice(1));this.attrs.deleted&&i.push(this.attrs.deleted);const n=[];for(const e of s){const r=await this.readMember(e,i);if(!r||u(r,this.attrs))continue;const s={};for(const[e,i]of Object.entries(t)){const t=i.startsWith("$")?m(f(r,i.slice(1))):i;void 0!==t&&(s[e]=t)}n.push(s)}return n}async readMember(e,t){try{const{searchEntries:r}=await this.server.ldap.search({paged:!1,scope:"base",attributes:t},e);if(r[0])return r[0]}catch(e){if(32!==s(e))throw e}const[r]=o(e),n=r?.indexOf("=")??-1;if(-1!==n)return{[r.slice(0,n).trim()]:i(r.slice(n+1).trim())}}resolve(e,t){if(!e.startsWith("$"))return e;if("$now"===e)return(new Date).toISOString();let r=e.slice(1);const s=r.endsWith("|domain");let i;s&&(r=r.slice(0,-7));let n=r;if(r.startsWith("dn."))i=t.dn[r.slice(3)],n="";else if(r.startsWith("context."))i=t.change[r.slice(8)],n="";else if(r.startsWith("previous."))n=r.slice(9),i=m(f(t.before,n));else if(r.startsWith("changed.")){n=r.slice(8);const e=f(t.after,n);(function(e,t){const list=e=>(void 0===e?[]:Array.isArray(e)?e:[e]).map(e=>e.toString()).sort();return list(e).join("\0")===list(t).join("\0")})(f(t.before,n),e)||(i=m(e)??"")}else i=m(f(t.after,n));return void 0!==i&&this.attrs.deletedAt&&n.toLowerCase()===this.attrs.deletedAt.toLowerCase()&&(i=b(i,this.attrs.deletedAtFormat)?.toISOString()),s&&(i=i?.split("@")[1]),i}async publish(t,r,s){const i=t.targets[r];if(!i?.length)return;const n=this.requirePlugin("rabbitmq");if(n)for(const t of i){if(!Object.entries(t.when).every(([e,t])=>{const r=this.resolve(e,s);return t.startsWith("!")?r!==t.slice(1):r===t}))continue;const i={};let o=!1,a=!1,c=!1;try{for(const[e,r]of Object.entries(t.payload)){if("string"!=typeof r){const t=await this.memberPayloads(r,s);t.length||"$members"===r.list||(c=!0),i[e]=t;continue}const t=this.resolve(r,s);r.startsWith("$changed.")&&(o=!0,void 0!==t&&(a=!0)),void 0!==t&&(i[e]=t)}}catch(e){this.logger.error({plugin:this.name,event:r,routingKey:t.routingKey,result:"members unreadable",error:String(e)});continue}if(c||o&&!a)continue;const l=e(),h={plugin:this.name,event:r,exchange:t.exchange,routingKey:t.routingKey,messageId:l};if(await n.getRawClient())try{await n.publish(t.exchange,t.routingKey,i,{messageId:l}),this.logger.info({...h,result:"published"})}catch(e){this.logger.error({...h,result:"error",error:String(e)})}else this.logger.error({...h,result:"no broker"})}}}export{g as LIFECYCLE_EVENTS,TwakeLifecycleEvents as default,parseRules};
@@ -0,0 +1 @@
1
+ import{randomUUID as e}from"node:crypto";import t from"../../abstract/plugin.js";import{BadRequestError as s}from"../../lib/errors.js";import{extractLdapCode as a}from"../../lib/ldapCodes.js";import{currentOperation as i}from"../../lib/operation.js";import{parseDn as r,unescapeDnValue as n,escapeDnValue as o,escapeLdapFilter as h}from"../../lib/utils.js";import{organizationIn as c,dnKey as d,movedInto as l,ORG as p,branchPattern as u,values as g,parentOf as m,read as f,rdnValue as w,notFound as b,RouteError as y,invalid as v,bodyOf as k,isDisplayName as O,NAME_RULE as $}from"./groups.js";import{isTombstone as _,valueOf as I,lifecycleAttributes as S}from"./lifecycleAttributes.js";const C=["viewer","editor","admin"],isRole=e=>C.includes(e),M=[...C].reverse(),L="twake.space.sync.requested",q=["name"],j=["uid","displayName","mail","jobTitle","role"],A=new RegExp(`:(${C.join("|")})$`,"i"),conflict=(e,t)=>new y(409,e,t),lastAdmin=()=>conflict("A space keeps at least one admin","LAST_ADMIN"),isAdmin=e=>"member"===e.kind&&"admin"===e.role;function roleOf(e){if(!isRole(e.role))throw v(`role must be one of: ${C.join(", ")}`);return e.role}function checkLength(e,t,s){if(e.length>s)throw v(`${t} cannot exceed ${s} entries per request`)}function namesOf(e,t,s){if(!Array.isArray(e)||0===e.length||!e.every(e=>"string"==typeof e&&e.length>0))throw v(`${t} must be a non-empty array of non-empty strings`);return checkLength(e,t,s),[...new Map(e.map(e=>[e.toLowerCase(),e])).values()]}function holdersOf(e,t,s){const a="id"===t?"groups":"members";if(!Array.isArray(e))throw v(`${a} must be an array of { ${t}, role }`);checkLength(e,a,s);const i=new Set;return e.map(e=>{const{[t]:s,role:r}=e??{};if("string"!=typeof s||0===s.length||!isRole(r))throw v(`${a} must be an array of { ${t}, role }, role one of: ${C.join(", ")}`);if(i.has(s.toLowerCase()))throw v(`${s} is listed twice in ${a}`);return i.add(s.toLowerCase()),{name:s,role:r}})}class TwakeSpaces extends t{constructor(e){if(super(e),this.name="twakeSpaces",this.roles=["api"],this.dependencies={twakeGroups:"core/twake/groups",onLdapChange:"core/ldap/onChange"},this.domains=new Map,this.following=Promise.resolve(),this.publishing=Promise.resolve(),this.unlinking=new Map,this.announced=new Map,this.handing=new Map,this.hooks={ldapaddrequest:([e,t,s])=>{for(const s of Object.values(this.roleAttributes))this.checkHolders(e,I(t,s));return[e,t,s]},ldapmodifyrequest:([e,t,s,a])=>{for(const s of Object.values(this.roleAttributes))for(const a of[t.add,t.replace])a&&this.checkHolders(e,I(a,s));return[e,t,s,a]},ldaprenamerequest:async([e,t,a])=>{const i=l(this.spacePattern,e,t);if(void 0===i)return[e,t,a];const r=Object.values(this.roleAttributes),n=await this.entry(e,r);if(n&&r.some(e=>void 0!==this.foreign(i,I(n,e))))throw new s(`The space holds users or groups of another organization than ${i}`);return[e,t,a]},ldapdeleterequest:async([e,t])=>{const s=i();if(void 0!==s)for(const t of[e].flat()){const e=c(this.userPattern,t);if(void 0!==e){if(this.tombstone?.keeps(t))continue;const a=await this.administered(e,t);if(!a.length)continue;this.handing.has(s)||this.handing.set(s,new Map),this.handing.get(s).set(d(t),{org:e,ids:a})}else if(this.rabbitmq){const e=await this.deletedGroup(t);e&&this.unlinking.set(d(t),{...e,op:s})}}return[e,t]},ldapdeletedone:(e,t={})=>{const s=i(),a=void 0===s?void 0:this.handing.get(s);for(const s of[e].flat()){const e=this.unlinking.get(d(s));e&&delete e.op;const i=a?.get(d(s));i&&this.queue(s,()=>this.handOver(i.org,s,i.ids,t))}},ldapdeleteend:e=>{this.handing.delete(e);for(const[t,s]of this.unlinking)if(s.op===e){this.unlinking.delete(t);for(const{id:e,context:t,fields:a}of s.owed??[])this.publishEvent(s.org,e,"group.unlinked",t,a)}},onLdapEntryChange:(e,t,s,a)=>{const i=this.orgPattern?.exec(r(e).join(","))?.groups;i&&this.rabbitmq&&(this.publishing=this.publishing.then(()=>{this.domains.delete(n(i.org).toLowerCase())})),this.follows&&this.queue(e,()=>this.follow(e,t,s,a));const o=c(this.userPattern,e);void 0!==o&&this.lifecycle.deleted&&t&&s&&!_(t,this.lifecycle)&&_(s,this.lifecycle)&&this.queue(e,async()=>this.handOver(o,e,await this.administered(o,e),a))}},this.spaceBase=this.config.twake_space_base||"",!this.spaceBase.includes(p))throw new Error(`${this.name}: --twake-space-base must hold ${p}`);this.spacePattern=u(this.spaceBase),this.userPattern=u(this.config.twake_group_user_base),this.lifecycle=S(this.config),this.displayName=this.config.twake_space_display_name_attribute||"twakeDisplayName",this.roleAttributes={admin:this.config.twake_space_admin_attribute||"twakeSpaceAdmin",editor:this.config.twake_space_editor_attribute||"twakeSpaceEditor",viewer:this.config.twake_space_viewer_attribute||"twakeSpaceViewer"},this.userRole=this.config.twake_space_user_role_attribute||"",this.exchange=this.config.twake_space_exchange||"space",this.domainAttribute=this.config.twake_instance_organization_domain_attribute||"twakeDomain",this.config.twake_group_organization_dn&&(this.orgPattern=u(this.config.twake_group_organization_dn)),this.config.rabbitmq_url&&(this.dependencies.rabbitmq="core/rabbitmq")}get follows(){return Boolean(this.userRole||this.config.rabbitmq_url)}get groups(){return this.server.loadedPlugins.twakeGroups}get tombstone(){return this.server.loadedPlugins.twakeTombstone}async assertComposition(){if(this.config.rabbitmq_url&&!await(this.rabbitmq?.getRawClient()))throw new Error(`${this.name}: RabbitMQ at --rabbitmq-url cannot be reached`);if(this.config.rabbitmq_url&&this.orgPattern&&!(await this.server.ldap.schemaIndex()).getAttributeType(this.domainAttribute))throw new Error(`${this.name}: the directory schema defines no ${this.domainAttribute}; load a schema that does, or set --twake-instance-organization-domain-attribute`);if(!this.follows||!this.server.loadedPlugins.trash)return;const type=e=>e.split("=")[0].toLowerCase(),e=[this.config.twake_group_base||"",this.spaceBase].map(e=>{const t=r(e).reverse();return e=>r(e).reverse().slice(0,t.length).every((e,s)=>t[s].includes(p)?type(e)===type(t[s]):d(e)===d(t[s]))}),t=String(this.config.trash_watched_bases||"").split(";").map(e=>e.trim()).filter(Boolean);if(0===t.length||t.some(t=>e.some(e=>e(t))))throw new Error(`${this.name}: core/ldap/trash watches the organization groups or spaces, and the deletion of one would not be followed: no user role value or event would say it went. Leave the group and space branches out of --trash-watched-bases`)}queue(e,t){this.following=this.following.then(()=>t().catch(t=>{this.logger.error({plugin:this.name,event:"follow",dn:e,error:String(t)})}))}organizationOf(e){return c(this.spacePattern,e)}spaceBaseOf(e){return this.spaceBase.replace(p,o(e))}spaceDn(e,t){return`cn=${o(t)},${this.spaceBaseOf(e)}`}async entry(e,t){try{return(await this.server.ldap.search({paged:!1,scope:"base",attributes:t},e)).searchEntries[0]}catch(e){if(32===a(e))return;throw e}}checkHolders(e,t){const a=this.organizationOf(e);if(!a)return;const i=this.foreign(a,t);if(void 0!==i)throw new s(`${i} is neither a user nor a group of organization ${a}`)}foreign(e,t){const s=[this.groups.userBaseOf(e),this.groups.groupBaseOf(e)].map(d);return g(t).find(e=>!s.includes(d(m(e))))}async api(e){const t=`${this.config.api_prefix}/v1/organizations/:id/spaces`,route=(e,t)=>this.groups.organizationRoute(e,t,this.name),done=e=>{e.json({success:!0})},spaceId=e=>e.params.spaceId;e.get(t,route("listing spaces",(e,t,s)=>this.listRoute(e,t,s))),e.post(t,route("creating space",(e,t,s)=>this.createRoute(e,t,s))),e.get(`${t}/:spaceId`,route("getting space",async(e,t,s)=>{t.json(this.spaceOf(s,await this.space(s,spaceId(e),e)))})),e.patch(`${t}/:spaceId`,route("renaming space",async(e,t,s)=>{const a=k(e),i=Object.keys(a).filter(e=>"name"!==e);if(i.length)throw v(`Only "name" can be updated; unsupported field(s): ${i.join(", ")}`);if(!O(a.name))throw v($);await this.write(s,spaceId(e),{replace:{[this.displayName]:a.name}},e),done(t)})),e.delete(`${t}/:spaceId`,route("deleting space",async(e,t,s)=>{try{await this.server.ldap.delete(this.spaceDn(s,spaceId(e)),e)}catch(e){if(32===a(e))throw b("space");throw e}done(t)})),e.get(`${t}/:spaceId/members`,route("listing space members",async(e,t,s)=>{const a=this.groups.page(e,j),i=await this.space(s,spaceId(e),e),r=i.holders.filter(e=>"member"===e.kind),n=new Map(r.map(e=>[e.name.toLowerCase(),e.role]));t.json({organizationId:s,id:i.id,...await this.groups.memberPage(a,s,r.map(e=>e.name),e=>({role:n.get(e.toLowerCase())}),e)})})),e.post(`${t}/:spaceId/members`,route("adding space members",async(e,t,s)=>{const a=k(e),i=namesOf(a.usernames,"usernames",this.groups.maxPage);await this.add(e,s,"member",roleOf(a),()=>this.liveUsers(s,i,e)),done(t)})),e.patch(`${t}/:spaceId/members/:userId`,route("changing space member role",async(e,t,s)=>{await this.change(e,s,"member",roleOf(k(e))),done(t)})),e.delete(`${t}/:spaceId/members/:userId`,route("removing space member",async(e,t,s)=>{await this.change(e,s,"member"),done(t)})),e.get(`${t}/:spaceId/groups`,route("listing space groups",async(e,t,s)=>{const a=await this.space(s,spaceId(e),e),i=a.holders.filter(e=>"group"===e.kind),r=new Map((await this.orgGroups(s,i.map(e=>e.name),[],e)).map(e=>[d(e.dn),this.groups.groupOf(s,e).displayName])),n=i.map(({name:e,role:t,dn:s})=>({id:e,name:r.get(d(s))??e,role:t})).sort((e,t)=>e.name.localeCompare(t.name));t.json({organizationId:s,id:a.id,groups:n})})),e.post(`${t}/:spaceId/groups`,route("linking space groups",async(e,t,s)=>{const a=k(e),i=namesOf(a.groupIds,"groupIds",this.groups.maxPage);await this.add(e,s,"group",roleOf(a),()=>this.groupDns(s,i,e)),done(t)})),e.patch(`${t}/:spaceId/groups/:groupId`,route("changing space group role",async(e,t,s)=>{await this.change(e,s,"group",roleOf(k(e))),done(t)})),e.delete(`${t}/:spaceId/groups/:groupId`,route("unlinking space group",async(e,t,s)=>{await this.change(e,s,"group"),done(t)})),await(this.rabbitmq?.subscribe(this.exchange,L,this.config.twake_space_sync_queue||`${L}.ldap-rest`,e=>this.onSyncRequested(e)))}spaceOf(e,t){return{id:t.id,name:t.name,organizationId:e,members:t.holders.filter(e=>"member"===e.kind).map(({name:e,role:t})=>({username:e,role:t})),groups:t.holders.filter(e=>"group"===e.kind).map(({name:e,role:t})=>({id:e,role:t}))}}entryOf(e,t,s){const a=new Map([[d(this.groups.userBaseOf(e)),"member"],[d(this.groups.groupBaseOf(e)),"group"]]),i=f(t,"cn")||"",r=new Map;for(const e of M)for(const i of g(I(t,this.roleAttributes[e]))){const t=d(i),n=a.get(d(m(i)));if(!n||s.has(t))continue;const o=r.get(t);o?o.roles.push(e):r.set(t,{kind:n,name:w(i),role:e,roles:[e],dn:i})}return{id:i,name:f(t,this.displayName)??i,holders:[...r.values()]}}async spaces(e,t,s,i,r){const[n,o]=await Promise.all([(async()=>{try{return(await this.server.ldap.search({paged:!1,scope:void 0===s?"one":"base",filter:t,attributes:["cn",this.displayName,...Object.values(this.roleAttributes)]},void 0===s?this.spaceBaseOf(e):this.spaceDn(e,s),i)).searchEntries}catch(e){if(32===a(e))return[];throw e}})(),r??this.hidden(e)]);return n.map(t=>this.entryOf(e,t,o))}async hidden(e){return new Set((await this.groups.tombstonesOf(e)).map(d))}holding(e){return`(|${e.flatMap(e=>Object.values(this.roleAttributes).map(t=>`(${t}=${h(e)})`)).join("")})`}async space(e,t,s){const[a]=await this.spaces(e,"(objectClass=*)",t,s);if(!a)throw b("space");return a}async write(e,t,s,i){try{await this.server.ldap.modify(this.spaceDn(e,t),s,i)}catch(e){if(32===a(e))throw b("space");throw e}}async again(e){for(let t=0;;t++)try{return await e()}catch(e){const s=a(e);if(16!==s&&20!==s||t>=2)throw e}}async liveUsers(e,t,s){const a=this.groups.userAttribute,i=new Map((await this.groups.users(e,t,[a],s)).map(e=>[(f(e,a)||"").toLowerCase(),e.dn]));if(t.some(e=>!i.has(e.toLowerCase())))throw b("user");return i}get groupName(){return this.config.twake_group_display_name_attribute||"twakeDisplayName"}async orgGroups(e,t,s=[],a){const i=this.groups.cn,r=[];for(let n=0;n<t.length;n+=this.groups.maxPage){const o=t.slice(n,n+this.groups.maxPage);r.push(...await this.groups.groups(e,`(|${o.map(e=>`(${i}=${h(e)})`).join("")})`,[i,this.groupName,...s],a))}return r}async follow(e,t,s,a){let i=this.organizationOf(e);if(void 0!==i){const[e,r]=[t,s].map(e=>e?this.entryOf(i,e,new Set):void 0),n=[...new Set([e,r].flatMap(e=>e?[e.id]:[]))],o=[e,r].map(e=>new Map(e?.holders.map(e=>[d(e.dn),e.role]))),h=[e,r].flatMap(e=>e?.holders??[]).filter(e=>n.length>1||o[0].get(d(e.dn))!==o[1].get(d(e.dn))),c=new Set(h.filter(e=>"group"===e.kind).map(e=>e.name)),l=await this.orgGroups(i,[...c],["member"]),p=(r??e).id,u=await this.settle(i,[...h.filter(e=>"member"===e.kind).map(e=>e.dn),...l.flatMap(e=>g(e.member))],{id:p,space:e},await this.hidden(i));return void(this.rabbitmq&&await this.announce(i,u,a,n,{was:e,is:r,linked:l}))}if(i=this.groups.organizationOf(e),void 0===i||!t)return;const r=g(t.member),n=g(s?.member),[o,h]=[r,n].map(e=>new Set(e.map(d))),c=s?void 0:this.unlinking.get(d(e));c&&this.unlinking.delete(d(e));const l=await this.hidden(i),p=await this.settle(i,[...r.filter(e=>!h.has(d(e))),...n.filter(e=>!o.has(d(e)))],{group:e,members:o,spaces:c?.spaces},l);if(!this.rabbitmq||!c&&!p.length)return;const u=(await this.spaces(i,this.holding([e]),void 0,void 0,l)).map(e=>e.id);c&&this.unlinked(e,c,u,a),await this.announce(i,p,a,[...c?.spaces.map(e=>e.id)??[],...u])}async administered(e,t){try{const s=`(${this.roleAttributes.admin}=${h(t)})`;return(await this.spaces(e,s)).map(e=>e.id)}catch(e){return this.logger.error({plugin:this.name,event:"handOver",dn:t,error:String(e)}),[]}}async handOver(e,t,s,a){for(const i of s)try{await this.again(()=>this.handOverOnce(e,t,i,a))}catch(e){this.logger.error({plugin:this.name,event:"handOver",space:i,error:String(e)})}}async handOverOnce(e,t,s,a){const i=d(t),[r]=await this.spaces(e,"(objectClass=*)",s),n=r?.holders.filter(e=>"member"===e.kind&&d(e.dn)!==i)??[];if(!r||n.some(e=>"admin"===e.role))return;if(!n.length)return void await this.server.ldap.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=>d(t.dn)===d(e));return s?[{id:t.id,role:s.role}]:[]});if(!s.length)return;const[a]=await this.orgGroups(t,[w(e)]),i=w(e);return{org:t,id:i,name:a&&f(a,this.groupName)||i,spaces:s}}catch(t){return void this.logger.error({plugin:this.name,event:"deleting",dn:e,error:String(t)})}}unlinked(e,t,s,a){const{org:i,id:r,name:n,spaces:o}=t,h=new Set(s.map(e=>e.toLowerCase()));for(const{id:t,role:s}of o){const o=d(this.spaceDn(i,t));h.has(t.toLowerCase())&&(this.announced.has(o)||this.announced.set(o,new Set),this.announced.get(o).add(d(e))),this.publishEvent(i,t,"group.unlinked",a,{groups:[{id:r,name:n,role:s}]})}}async settle(e,t,s,a){const i=d(this.groups.userBaseOf(e)),r=new Map(t.filter(e=>d(m(e))===i).map(e=>[d(e),e])),n=[];for(const[t,i]of r)if(!a.has(t))try{const t=await this.rolesOf(e,i,a),r=this.userRole?await this.writeRoles(i,t):await this.rolesOf(e,i,a,s);r&&n.push({dn:i,before:r,after:t})}catch(e){this.logger.error({plugin:this.name,event:"settle",dn:i,error:String(e)})}return n}async userIn(e,t,s,a){const i=(await this.groups.groups(e,`(member=${h(t)})`,[this.groups.cn],s)).map(e=>d(e.dn)),r=new Set([d(t),...i]),n=[t,...i];if(a&&"group"in a){const e=d(a.group);r.delete(e),a.members.has(d(t))&&r.add(e),n.push(a.group)}return{filter:this.holding(n),roleIn:e=>e.holders.find(e=>r.has(d(e.dn)))?.role}}async announce(e,t,s,a,i){const r=new Set;if(i){const{was:a,is:n,linked:o}=i,h=(n??a).id,c=new Map(o.map(e=>[d(e.dn),f(e,this.groupName)||w(e.dn)])),groupOf=e=>({id:e.name,name:c.get(d(e.dn))??e.name,role:e.role}),publish=(t,a)=>this.publishEvent(e,h,t,s,a),l=d(this.spaceDn(e,h)),p=new Set;for(const t of[a,n]){const s=t&&d(this.spaceDn(e,t.id));for(const e of s&&this.announced.get(s)||[])p.add(e);s&&this.announced.delete(s)}if(a&&n){a.name!==n.name&&publish("updated",{name:n.name});const e=new Map(a.holders.filter(e=>"group"===e.kind).map(e=>[d(e.dn),e]));for(const t of n.holders.filter(e=>"group"===e.kind)){const s=e.get(d(t.dn));e.delete(d(t.dn)),s?.role!==t.role&&publish(s?"group.role.changed":"group.linked",{groups:[groupOf(t)]})}const byDeletion=e=>{if(p.delete(d(e.dn)))return!0;const t=this.unlinking.get(d(e.dn));return!!t?.spaces.some(e=>e.id.toLowerCase()===h.toLowerCase())&&(void 0!==t.op&&(t.owed??(t.owed=[])).push({id:h,context:s,fields:{groups:[groupOf(e)]}}),!0)};for(const t of e.values())byDeletion(t)||publish("group.unlinked",{groups:[groupOf(t)]});const[t,i]=[a,n].map(e=>new Set(e.holders.map(e=>d(e.dn)))),r=[...p].filter(e=>t.has(e)&&i.has(e));r.length&&this.announced.set(l,new Set(r))}else if(r.add(h.toLowerCase()),n){const s=t.flatMap(e=>{const t=e.after.get(h);return t?[{dn:e.dn,role:t}]:[]}),a=await this.profiles(e,s.map(e=>e.dn));publish("created",{name:n.name,members:s.flatMap(({dn:e,role:t})=>{const s=a.get(d(e));return s?[{...s,role:t}]:[]}),groups:n.holders.filter(e=>"group"===e.kind).map(groupOf)})}else publish("deleted")}const n=new Set(a.map(e=>e.toLowerCase())),o=t.flatMap(({dn:e,before:t,after:s})=>[...new Set([...t.keys(),...s.keys()])].flatMap(a=>{const[i,o]=[t.get(a),s.get(a)];if(i===o||r.has(a.toLowerCase()))return[];const h=!n.has(a.toLowerCase());if(h&&!this.userRole)return[];return[{dn:e,id:a,event:i?o?"member.role.changed":"member.removed":"member.added",role:o??i,caught:h}]}));if(!o.length)return;const h=await this.profiles(e,o.map(e=>e.dn));for(const{dn:t,id:a,event:i,role:r,caught:n}of o){const o=h.get(d(t));o&&this.publishEvent(e,a,i,n?{}:s,{members:[{...o,role:r}]})}}async profiles(e,t){if(!t.length)return new Map;const s=this.groups.userAttribute,a=this.config.mail_attribute||"mail",i=new Map((await this.groups.users(e,[...new Set(t.map(w))],[s,"entryUUID",a,"givenName","sn"])).map(e=>[(f(e,s)||"").toLowerCase(),e]));return new Map(t.flatMap(e=>{const t=i.get(w(e).toLowerCase());if(!t)return[];const r={uuid:f(t,"entryUUID"),username:f(t,s),email:f(t,a),firstName:f(t,"givenName"),lastName:f(t,"sn")};return[[d(e),r]]}))}publishEvent(e,t,s,a,i={}){this.enqueue(e,s,{id:t,...i,actor:a.actor},(new Date).toISOString())}enqueue(e,t,s,a){const i=this.rabbitmq;if(!i)return Promise.resolve();const r=this.publishing.then(async()=>this.publish(i,t,{organizationId:e,...await this.domainOf(e),...s,timestamp:a}));return this.publishing=r.catch(()=>{}),r}async onSyncRequested(t){const malformed=()=>new Error(`${this.name}: malformed sync request ${JSON.stringify(t)}`);if("object"!=typeof t||!t||Array.isArray(t))throw malformed();const{organizationId:s,id:a}=t,valid=e=>void 0===e||"string"==typeof e&&e.length>0;if(!valid(s)||!valid(a)||void 0===s&&void 0!==a)throw malformed();if(void 0===s){for(const t of await this.organizations())await this.rabbitmq.publish(this.exchange,L,{organizationId:t,timestamp:(new Date).toISOString()},{messageId:e()});return}let i=!1;try{await this.groups.checkOrganization(s)}catch(e){if(!(e instanceof y))throw e;if(this.logger.info({plugin:this.name,event:"sync",organization:s,result:e.code}),410!==e.status)return;i=!0}await this.sync(s,a,i)}async organizations(){const e=r(this.spaceBase),t=e.findIndex(e=>e.includes(p)),s=e.slice(0,t),a=[],i=await this.server.ldap.search({paged:{pageSize:500},scope:"one",filter:"(objectClass=*)",attributes:["dn"]},e.slice(t+1).join(","));for await(const e of i)for(const{dn:t}of e.searchEntries){const e=c(this.spacePattern,["cn=x",...s,t].join(","));void 0!==e&&a.push(e)}return a}async sync(e,t,s){const a=(new Date).toISOString(),i=s?[]:await this.spaces(e,"(objectClass=*)",t),r=[];void 0===t||i.length||r.push(this.enqueue(e,"deleted",{id:t},a));for(const t of i)r.push(this.enqueue(e,"synced",await this.whole(e,t),a));void 0===t&&r.push(this.enqueue(e,"sync.completed",{spaceIds:i.map(e=>e.id)},a));const n=(await Promise.allSettled(r)).find(e=>"rejected"===e.status);if(n)throw n.reason}async whole(e,t){const s=d(this.groups.userBaseOf(e)),a=await this.orgGroups(e,t.holders.filter(e=>"group"===e.kind).map(e=>e.name),["member"]),i=new Map(a.map(e=>[d(e.dn),{name:f(e,this.groupName)||w(e.dn),members:g(e.member)}])),r=new Map,hold=(e,t)=>{r.has(d(e))||r.set(d(e),{dn:e,role:t})};for(const e of t.holders)if("member"===e.kind)hold(e.dn,e.role);else for(const t of i.get(d(e.dn))?.members??[])d(m(t))===s&&hold(t,e.role);const n=[...r.values()],o=await this.profiles(e,n.map(e=>e.dn));return{id:t.id,name:t.name,members:n.flatMap(({dn:e,role:t})=>{const s=o.get(d(e));return s?[{...s,role:t}]:[]}),groups:t.holders.filter(e=>"group"===e.kind).map(e=>({id:e.name,name:i.get(d(e.dn))?.name??e.name,role:e.role}))}}async domainOf(e){const t=this.config.twake_group_organization_dn;if(!t)return{};const s=e.toLowerCase();let a=this.domains.get(s);if(void 0===a){const i=this.domainAttribute;try{const s=await this.entry(t.replace(p,o(e)),[i]);a=s&&f(s,i)}catch(t){this.logger.warn({plugin:this.name,event:"organizationDomain",organization:e,error:String(t)})}a&&this.domains.set(s,a)}return a?{organizationDomain:a}:{}}get rabbitmq(){return this.config.rabbitmq_url?this.requirePlugin("rabbitmq"):null}async publish(t,s,a){const i={plugin:this.name,exchange:this.exchange,routingKey:`twake.space.${s}`,messageId:e()};if(!await t.getRawClient())throw this.logger.error({...i,result:"no broker"}),new Error(`${this.name}: no broker for ${i.routingKey}`);try{await t.publish(i.exchange,i.routingKey,a,{messageId:i.messageId})}catch(e){throw this.logger.error({...i,result:"error",error:String(e)}),e}this.logger.info({...i,result:"published"})}async rolesOf(e,t,s,a){const{filter:i,roleIn:r}=await this.userIn(e,t,void 0,a);let n=await this.spaces(e,i,void 0,void 0,s);if(a&&"id"in a){const e=a.id.toLowerCase();n=n.filter(t=>t.id.toLowerCase()!==e),a.space&&n.push(a.space)}const o=new Map(n.flatMap(e=>{const t=r(e);return t?[[e.id,t]]:[]}));if(a&&"group"in a&&a.members.has(d(t)))for(const{id:e,role:t}of a.spaces??[]){const s=o.get(e);(!s||C.indexOf(t)>C.indexOf(s))&&o.set(e,t)}return o}async writeRoles(e,t){const s=this.userRole,i=[...t].map(([e,t])=>`${e}:${t}`),r=new Set(i.map(e=>e.toLowerCase()));for(let t=0;;t++)try{const[t]=(await this.server.ldap.search({paged:!1,scope:"base",attributes:[s]},e)).searchEntries;if(!t)return;const a=g(I(t,s)),n=new Set(a.map(e=>e.toLowerCase())),o=a.filter(e=>A.test(e)&&!r.has(e.toLowerCase())),h=i.filter(e=>!n.has(e.toLowerCase()));return(o.length||h.length)&&await this.server.ldap.modify(e,{...o.length&&{delete:{[s]:o}},...h.length&&{add:{[s]:h}}}),new Map(a.flatMap(e=>{const t=A.exec(e)?.[1].toLowerCase();return t?[[e.slice(0,e.lastIndexOf(":")),t]]:[]}))}catch(e){const s=a(e);if(32===s)return;if(16!==s&&20!==s||t>=2)throw e}}async groupDns(e,t,s){const a=new Map((await this.orgGroups(e,t,[],s)).map(e=>[(f(e,this.groups.cn)||"").toLowerCase(),e.dn]));if(t.some(e=>!a.has(e.toLowerCase())))throw b("group");return a}async add(e,t,s,a,i){await this.again(()=>this.addOnce(e,t,s,a,i))}async addOnce(e,t,s,a,i){const r=await this.space(t,e.params.spaceId,e),n=await i(),o=new Map(r.holders.filter(e=>e.kind===s).map(e=>[e.name.toLowerCase(),e.role])),h=[];for(const[e,t]of n){const i=o.get(e);if(void 0===i)h.push(t);else if(i!==a)throw"member"===s?conflict(`${e} is already a member, as ${i}`,"MEMBER_EXISTS"):conflict(`${e} is already linked, as ${i}`,"GROUP_ALREADY_LINKED")}h.length&&await this.write(t,r.id,{add:{[this.roleAttributes[a]]:h}},e)}async change(e,t,s,a){await this.again(()=>this.changeOnce(e,t,s,a))}async changeOnce(e,t,s,a){const i=await this.space(t,e.params.spaceId,e),r="member"===s?e.params.userId:e.params.groupId,n=i.holders.find(e=>e.kind===s&&e.name.toLowerCase()===r.toLowerCase());if(!n)throw b(s);if(1===n.roles.length&&n.role===a)return;const o="member"===s&&"admin"===n.role&&"admin"!==a;if(o&&!i.holders.some(e=>e!==n&&isAdmin(e)))throw lastAdmin();const h=Object.fromEntries(n.roles.filter(e=>e!==a).map(e=>[this.roleAttributes[e],n.dn])),c=a&&!n.roles.includes(a)?{[this.roleAttributes[a]]:n.dn}:void 0;if(await this.write(t,i.id,{delete:h,add:c},e),o&&!(await this.space(t,i.id,e)).holders.some(isAdmin))throw await this.write(t,i.id,{add:h,delete:c},e),lastAdmin()}async listRoute(e,t,s){const a=this.groups.page(e,q);let i="(cn=*)";a.search&&(i=`(&${i}(${this.displayName}=*${h(a.search)}*))`);const r=e.query.user;let n;if(void 0!==r){if("string"!=typeof r||!r)throw v("user must be a username");const[t]=(await this.liveUsers(s,[r],e)).values(),a=await this.userIn(s,t,e);i=`(&${i}${a.filter})`,n=a.roleIn}const o=this.groups.sorted((await this.spaces(s,i,void 0,e)).map(e=>({...this.spaceOf(s,e),...n&&{role:n(e)}})),a.sortBy,a.desc);t.json({organizationId:s,spaces:o.slice(a.offset,a.offset+a.limit),pagination:{page:a.page,limit:a.limit,total:o.length,totalPages:Math.ceil(o.length/a.limit)}})}async createRoute(t,s,a){const i=k(t);if(!O(i.name))throw v($);const r=this.groups.maxPage,n=holdersOf(i.members,"username",r),o=holdersOf(i.groups??[],"id",r);if(!n.some(e=>"admin"===e.role))throw v("A space needs an admin among its members","ADMIN_REQUIRED");const[h,c]=await Promise.all([this.liveUsers(a,n.map(e=>e.name),t),this.groupDns(a,o.map(e=>e.name),t)]),d=e(),l={objectClass:this.config.twake_space_class||["top","twakeSpace"],cn:d,[this.displayName]:i.name};for(const[e,t]of[[n,h],[o,c]])for(const{name:s,role:a}of e){const e=this.roleAttributes[a];l[e]=[...g(l[e]),t.get(s.toLowerCase())]}await this.server.ldap.add(this.spaceDn(a,d),l,t),s.status(201).json(this.spaceOf(a,await this.space(a,d,t)))}}export{C as SPACE_ROLES,TwakeSpaces as default};
@@ -1 +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,15 @@ 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;
240
+ twake_space_sync_queue?: string;
232
241
  twake_instance_dn?: string[];
233
242
  twake_instance_skip_attribute?: string;
234
243
  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
- private checkOrganization;
116
+ checkOrganization(org: string): Promise<void>;
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,212 @@
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
+ private readonly domainAttribute;
34
+ private readonly orgPattern?;
35
+ private readonly domains;
36
+ /**
37
+ * Changes are followed one at a time, each from the directory as it is
38
+ * then, so the order they are followed in does not matter.
39
+ */
40
+ private following;
41
+ private publishing;
42
+ /**
43
+ * The spaces of a group being deleted, by DN key, read before refint takes
44
+ * it out of them, until its deletion is followed. `op` is the delete
45
+ * still running, `owed` the unlinks space writes left to it meanwhile.
46
+ */
47
+ private readonly unlinking;
48
+ /**
49
+ * The groups a deletion announced unlinked, by space DN key, until a write
50
+ * of the space takes their DN out of it, or finds it gone already.
51
+ */
52
+ private readonly announced;
53
+ /**
54
+ * The spaces a user being deleted is an admin of, by operation and DN,
55
+ * read before refint takes the user out of them.
56
+ */
57
+ private readonly handing;
58
+ constructor(server: DM);
59
+ /** Whether user roles are kept or events published. */
60
+ private get follows();
61
+ private get groups();
62
+ private get tombstone();
63
+ hooks: Hooks;
64
+ /**
65
+ * core/rabbitmq connects lazily and hands back no client when it cannot:
66
+ * every event would then be lost, so the server does not start.
67
+ *
68
+ * The organization domain is only read, and a search for an attribute the
69
+ * schema does not define finds none: every event would go out without it.
70
+ *
71
+ * core/ldap/trash moves a deleted group or space away instead of deleting
72
+ * it: nothing follows the move, so users would keep the roles it gave,
73
+ * and no event would say it went.
74
+ */
75
+ assertComposition(): Promise<void>;
76
+ private queue;
77
+ /** The organization a space DN belongs to, if it is a space at all. */
78
+ organizationOf(dn: string): string | undefined;
79
+ spaceBaseOf(org: string): string;
80
+ spaceDn(org: string, id: string): string;
81
+ /** An entry as it is, read for no one, or nothing for one missing. */
82
+ private entry;
83
+ /** A space holds users and groups of its organization, whichever API writes it. */
84
+ private checkHolders;
85
+ /** A holder that is neither a user nor a group of the organization. */
86
+ private foreign;
87
+ api(app: Express): Promise<void>;
88
+ /** A space as the routes answer it, strongest role first. */
89
+ private spaceOf;
90
+ /**
91
+ * The users and groups an entry holds, strongest role first, each once
92
+ * with its strongest role. A tombstone is hidden.
93
+ */
94
+ private entryOf;
95
+ /** The spaces of the organization matching a filter, or the one of an id. */
96
+ private spaces;
97
+ /**
98
+ * Whoever asks: a tombstone outside the caller's branches would otherwise
99
+ * count as a member, and as an admin.
100
+ */
101
+ private hidden;
102
+ /** A filter for the spaces holding any of these DNs, whatever the role. */
103
+ private holding;
104
+ private space;
105
+ private write;
106
+ /**
107
+ * A change decided on a space read before another request wrote it fails
108
+ * with LDAP 16 or 20: it is decided again on the space as it is now.
109
+ */
110
+ private again;
111
+ /** The live users of the organization by lowercased name, as DNs, or a 404. */
112
+ private liveUsers;
113
+ private get groupName();
114
+ /** Groups of the organization by id, read in pages of the size limit. */
115
+ private orgGroups;
116
+ /**
117
+ * The users a space write or a change of a group's members may have
118
+ * moved, and the events of the write. A deleted group is followed through
119
+ * its members as they were.
120
+ */
121
+ private follow;
122
+ /**
123
+ * The spaces a user is an admin of. Read again when a tombstone is erased:
124
+ * its hand-over may have failed, or predate this plugin, and handing over
125
+ * a space that keeps another admin does nothing.
126
+ */
127
+ private administered;
128
+ /**
129
+ * A space a deleted user was the last admin of goes to its editors, else
130
+ * its viewers; one left with no member is deleted.
131
+ */
132
+ private handOver;
133
+ private handOverOnce;
134
+ /** A group about to be deleted, if it is one linked to spaces. */
135
+ private deletedGroup;
136
+ /**
137
+ * The spaces a deleted group left. One still holding its DN, without
138
+ * refint, remembers the announcement for the write that takes it out.
139
+ */
140
+ private unlinked;
141
+ /**
142
+ * Rewrite each user's roles from the directory as it is now: a change
143
+ * only tells which users to look at, so two changes followed in either
144
+ * order, or late, leave the same roles.
145
+ *
146
+ * The roles each user had come from the values written before, or, with
147
+ * no user role attribute, from the directory with the change undone: two
148
+ * changes followed late may then announce a role twice, or not at all.
149
+ */
150
+ private settle;
151
+ /**
152
+ * A filter for the spaces a user is in, directly or through a group, and
153
+ * their role in one of them. With a group's members undone, as they were.
154
+ */
155
+ private userIn;
156
+ /**
157
+ * The events of a followed change: the space write itself, then each
158
+ * moved user's roles, except in a space created or deleted by it.
159
+ *
160
+ * A role may have moved in a space the change did not touch, by another
161
+ * change not followed yet. Without the role attribute, that change
162
+ * announces it. With it, this follow wrote the value, so that change
163
+ * will find nothing to announce: the role goes out here, without an
164
+ * actor, as this change's is not the one that moved it.
165
+ */
166
+ private announce;
167
+ /** Users as space events name them, by DN key, live users only. */
168
+ private profiles;
169
+ private publishEvent;
170
+ private enqueue;
171
+ /**
172
+ * Every organization is asked in a request of its own, through the queue:
173
+ * the broker closes the channel of a consumer that holds a message longer
174
+ * than its consumer_timeout (30 minutes by default), and delivers every
175
+ * message in flight on it again.
176
+ */
177
+ private onSyncRequested;
178
+ /** The entries where `--twake-space-base` puts an organization. */
179
+ private organizations;
180
+ /**
181
+ * The events bear the time before the read, so an event of a later write
182
+ * is newer. Fails once they are all out if one was not sent, so the
183
+ * request is retried.
184
+ */
185
+ private sync;
186
+ /** A space as `twake.space.created` gives it. */
187
+ private whole;
188
+ /** Kept once found, until the organization entry changes. */
189
+ private domainOf;
190
+ private get rabbitmq();
191
+ private publish;
192
+ /** A user's roles, in the directory as it is or with a change undone. */
193
+ private rolesOf;
194
+ /**
195
+ * Write a user's `<space id>:<role>` values, and answer the roles they
196
+ * held, or nothing for a user gone.
197
+ */
198
+ private writeRoles;
199
+ /** The groups of the organization by lowercased id, as DNs, or a 404. */
200
+ private groupDns;
201
+ /** Each of a space's members or groups holds one role. */
202
+ private add;
203
+ private addOnce;
204
+ /**
205
+ * Move a member or group to another role, or out of the space without
206
+ * one. A space keeps at least one admin among its users.
207
+ */
208
+ private change;
209
+ private changeOnce;
210
+ private listRoute;
211
+ private createRoute;
212
+ }
@@ -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