ldap-rest 1.2.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- import{scimInvalidValue as t}from"./errors.js";import{SCHEMA_USER as e,SCHEMA_GROUP as r}from"./types.js";const o=["schemas","id"];function stripCoreUrn(t){for(const o of[e,r])if(t.toLowerCase().startsWith(`${o.toLowerCase()}:`))return t.slice(o.length+1);return t}function groupPaths(t){const e=new Map;for(const r of t){const t=stripCoreUrn(r.trim());if(!t)continue;const o=t.startsWith("urn:")?t.indexOf(".",t.lastIndexOf(":")):t.indexOf("."),n=(o>0?t.slice(0,o):t).toLowerCase(),s=o>0?t.slice(o+1).toLowerCase():"";if(!s){e.set(n,null);continue}const c=e.get(n);null!==c&&(c?c.add(s):e.set(n,new Set([s])))}return e}function narrow(t,e){const pick=t=>{const r={};for(const[o,n]of Object.entries(t))e.has(o.toLowerCase())&&(r[o]=n);return r};if(Array.isArray(t)){return t.map(t=>t&&"object"==typeof t&&!Array.isArray(t)?pick(t):t).filter(t=>!(t&&"object"==typeof t&&0===Object.keys(t).length))}return t&&"object"==typeof t?pick(t):t}function without(t,e){const drop=t=>{const r={};for(const[o,n]of Object.entries(t))e.has(o.toLowerCase())||(r[o]=n);return r};return Array.isArray(t)?t.map(t=>t&&"object"==typeof t&&!Array.isArray(t)?drop(t):t):t&&"object"==typeof t?drop(t):t}function assertProjection(e){if(e.attributes?.length&&e.excludedAttributes?.length)throw t("'attributes' and 'excludedAttributes' are mutually exclusive (RFC 7644 section 3.9)")}function projectedToNothing(t){return Array.isArray(t)?0===t.length:"object"==typeof t&&null!==t&&0===Object.keys(t).length}function projectResource(t,e){if(e.attributes?.length){const r=groupPaths([...o,...e.attributes]),n={};for(const[e,o]of Object.entries(t)){if(!r.has(e.toLowerCase()))continue;const t=r.get(e.toLowerCase());if(!t){n[e]=o;continue}const s=narrow(o,t);projectedToNothing(s)||(n[e]=s)}return n}if(e.excludedAttributes?.length){const r=groupPaths(e.excludedAttributes),n=new Set(o),s={};for(const[e,o]of Object.entries(t)){const t=e.toLowerCase(),c=r.get(t);if(void 0===c){s[e]=o;continue}if(null===c){n.has(t)&&(s[e]=o);continue}const i=without(o,c);projectedToNothing(i)||(s[e]=i)}return s}return t}function projectList(t,e){return e.attributes?.length||e.excludedAttributes?.length?{...t,Resources:t.Resources.map(t=>projectResource(t,e))}:t}export{assertProjection,projectList,projectResource};
1
+ import{scimInvalidValue as t}from"./errors.js";import{SCHEMA_USER as e,SCHEMA_GROUP as o}from"./types.js";const r=["schemas","id"];function stripCoreUrn(t){for(const r of[e,o])if(t.toLowerCase().startsWith(`${r.toLowerCase()}:`))return t.slice(r.length+1);return t}function groupPaths(t){const e=new Map;for(const o of t){const t=stripCoreUrn(o.trim());if(!t)continue;const r=t.startsWith("urn:")?t.indexOf(".",t.lastIndexOf(":")):t.indexOf("."),n=(r>0?t.slice(0,r):t).toLowerCase(),s=r>0?t.slice(r+1).toLowerCase():"";if(!s){e.set(n,null);continue}const c=e.get(n);null!==c&&(c?c.add(s):e.set(n,new Set([s])))}return e}const nonEmpty=t=>t.filter(t=>!(t&&"object"==typeof t&&0===Object.keys(t).length));function narrow(t,e){const pick=t=>{const o={};for(const[r,n]of Object.entries(t))e.has(r.toLowerCase())&&(o[r]=n);return o};return Array.isArray(t)?nonEmpty(t.map(t=>t&&"object"==typeof t&&!Array.isArray(t)?pick(t):t)):t&&"object"==typeof t?pick(t):t}function without(t,e){const drop=t=>{const o={};for(const[r,n]of Object.entries(t))e.has(r.toLowerCase())||(o[r]=n);return o};return Array.isArray(t)?nonEmpty(t.map(t=>t&&"object"==typeof t&&!Array.isArray(t)?drop(t):t)):t&&"object"==typeof t?drop(t):t}function assertProjection(e){if(e.attributes?.length&&e.excludedAttributes?.length)throw t("'attributes' and 'excludedAttributes' are mutually exclusive (RFC 7644 section 3.9)")}function projectedToNothing(t){return Array.isArray(t)?0===t.length:"object"==typeof t&&null!==t&&0===Object.keys(t).length}function projectResource(t,e){if(e.attributes?.length){const o=groupPaths([...r,...e.attributes]),n={};for(const[e,r]of Object.entries(t)){if(!o.has(e.toLowerCase()))continue;const t=o.get(e.toLowerCase());if(!t){n[e]=r;continue}const s=narrow(r,t);projectedToNothing(s)||(n[e]=s)}return n}if(e.excludedAttributes?.length){const o=groupPaths(e.excludedAttributes),n=new Set(r),s={};for(const[e,r]of Object.entries(t)){const t=e.toLowerCase(),c=o.get(t);if(void 0===c){s[e]=r;continue}if(null===c){n.has(t)&&(s[e]=r);continue}const i=without(r,c);projectedToNothing(i)||(s[e]=i)}return s}return t}function projectList(t,e){return e.attributes?.length||e.excludedAttributes?.length?{...t,Resources:t.Resources.map(t=>projectResource(t,e))}:t}export{assertProjection,projectList,projectResource};
@@ -1 +1 @@
1
- import{fileURLToPath as s}from"url";import{join as e,dirname as i}from"path";import t from"../../abstract/plugin.js";import{setChangeSource as o}from"../../lib/changeContext.js";import{jsonBodyParser as r}from"../../lib/rawBody.js";import{staticPlugins as a}from"../../lib/staticFiles.js";import{BaseResolver as c}from"./baseResolver.js";import{ScimUsers as n}from"./users.js";import{ScimGroups as p}from"./groups.js";import{ScimBulk as m}from"./bulk.js";import{ScimDiscovery as u}from"./discovery.js";import{SCIM_CONTENT_TYPE as d,scimAsyncHandler as h,writeScimError as l}from"./errors.js";export{writeScimErrorFromException}from"./errors.js";import{projectResource as g,assertProjection as f,projectList as y}from"./projection.js";function scimBodyParser(s){return r({type:["application/json","application/scim+json"],limit:`${s.scim_bulk_max_payload_size||1048576}b`})}class Scim extends t{constructor(t){super(t),this.name="scim",this.roles=["api","configurable"],this.ldap=t.ldap,this.scimPrefix=this.config.scim_prefix||"/scim/v2",this.baseResolver=new c(this.config),this.users=new n({ldap:this.ldap,config:this.config,logger:this.logger,baseResolver:this.baseResolver,hooks:this.registeredHooks}),this.groups=new p({ldap:this.ldap,config:this.config,logger:this.logger,baseResolver:this.baseResolver,users:this.users,hooks:this.registeredHooks}),this.bulk=new m({config:this.config,logger:this.logger,users:this.users,groups:this.groups,scimPrefix:this.scimPrefix,hooks:this.registeredHooks});const o=this.config.schemas_path||e(i(s(import.meta.url)),"..","..","..","static","schemas");this.scimSchemaDir=e(o,"scim"),this.discovery=new u({config:this.config,schemaDir:this.scimSchemaDir,scimPrefix:this.scimPrefix,loadedPlugins:t.loadedPlugins})}scimJson(s,e,i){s.status(e).type(d).json(i)}scimResource(s,e,i,t,o={}){const r=i.id?`${this.scimPrefix}/${t}/${encodeURIComponent(i.id)}`:void 0,a=(this.config.scim_base_url||"")&&i.meta?.location?i.meta.location:r;a&&s.set("Location",a),this.scimJson(s,e,g(i,o))}parseProjection(s){const toCsv=s=>{if("string"!=typeof s)return;const e=s.split(",").map(s=>s.trim()).filter(Boolean);return e.length>0?e:void 0},e={attributes:toCsv(s.query.attributes),excludedAttributes:toCsv(s.query.excludedAttributes)};return f(e),e}readScimBody(s){const e=String(s.headers["content-type"]||"");if(e.startsWith("application/scim+json")||e.startsWith("application/json"))return s.body}api(s){const e=this.scimPrefix;this.server.registerBodyParser(e,scimBodyParser(this.config)),s.use(e,(s,e,i)=>{o(s,"scim"),i()}),s.use(e,h((s,e,i)=>{this.parseProjection(s),i()})),s.get(`${e}/Users`,h(async(s,e)=>{const i=this.parseListQuery(s);f(i);const t=await this.users.list(s,i);this.scimJson(e,200,y(t,i))})),s.get(`${e}/Users/:id`,h(async(s,e)=>{const i=this.parseProjection(s),t=await this.users.get(s,decodeURIComponent(s.params.id));this.scimJson(e,200,g(t,i))})),s.post(`${e}/Users`,h(async(s,e)=>{const i=this.readScimBody(s);if(!i||"object"!=typeof i)return l(e,400,"Missing body","invalidSyntax");const t=await this.users.create(s,i);this.scimResource(e,201,t,"Users",this.parseProjection(s))})),s.put(`${e}/Users/:id`,h(async(s,e)=>{const i=this.readScimBody(s);if(!i||"object"!=typeof i)return l(e,400,"Missing body","invalidSyntax");const t=await this.users.replace(s,decodeURIComponent(s.params.id),i);this.scimResource(e,200,t,"Users",this.parseProjection(s))})),s.patch(`${e}/Users/:id`,h(async(s,e)=>{const i=this.readScimBody(s);if(!i||"object"!=typeof i)return l(e,400,"Missing body","invalidSyntax");const t=await this.users.patch(s,decodeURIComponent(s.params.id),i);this.scimResource(e,200,t,"Users",this.parseProjection(s))})),s.delete(`${e}/Users/:id`,h(async(s,e)=>{await this.users.delete(s,decodeURIComponent(s.params.id)),e.status(204).end()})),s.get(`${e}/Groups`,h(async(s,e)=>{const i=this.parseListQuery(s);f(i);const t=await this.groups.list(s,i);this.scimJson(e,200,y(t,i))})),s.get(`${e}/Groups/:id`,h(async(s,e)=>{const i=this.parseProjection(s),t=await this.groups.get(s,decodeURIComponent(s.params.id));this.scimJson(e,200,g(t,i))})),s.post(`${e}/Groups`,h(async(s,e)=>{const i=this.readScimBody(s);if(!i||"object"!=typeof i)return l(e,400,"Missing body","invalidSyntax");const t=await this.groups.create(s,i);this.scimResource(e,201,t,"Groups",this.parseProjection(s))})),s.put(`${e}/Groups/:id`,h(async(s,e)=>{const i=this.readScimBody(s);if(!i||"object"!=typeof i)return l(e,400,"Missing body","invalidSyntax");const t=await this.groups.replace(s,decodeURIComponent(s.params.id),i);this.scimResource(e,200,t,"Groups",this.parseProjection(s))})),s.patch(`${e}/Groups/:id`,h(async(s,e)=>{const i=this.readScimBody(s);if(!i||"object"!=typeof i)return l(e,400,"Missing body","invalidSyntax");const t=await this.groups.patch(s,decodeURIComponent(s.params.id),i);this.scimResource(e,200,t,"Groups",this.parseProjection(s))})),s.delete(`${e}/Groups/:id`,h(async(s,e)=>{await this.groups.delete(s,decodeURIComponent(s.params.id)),e.status(204).end()})),s.post(`${e}/Bulk`,h(async(s,e)=>{const i=this.readScimBody(s);if(!i||"object"!=typeof i)return l(e,400,"Missing body","invalidSyntax");const t=this.config.scim_bulk_max_payload_size||1048576,o=JSON.stringify(i).length;if(o>t)return l(e,413,`Bulk payload too large: ${o} > ${t}`,"invalidValue");const r=await this.bulk.execute(s,i);this.scimJson(e,200,r)})),s.get(`${e}/ServiceProviderConfig`,h((s,e)=>{this.scimJson(e,200,this.discovery.serviceProviderConfig(s))})),s.get(`${e}/ResourceTypes`,h((s,e)=>{const i=this.discovery.resourceTypes(s);this.scimJson(e,200,{schemas:["urn:ietf:params:scim:api:messages:2.0:ListResponse"],totalResults:i.length,Resources:i,startIndex:1,itemsPerPage:i.length})})),s.get(`${e}/ResourceTypes/:name`,h((s,e)=>{const i=this.discovery.resourceType(s.params.name,s);if(!i)return l(e,404,"ResourceType not found");this.scimJson(e,200,i)})),s.get(`${e}/Schemas`,h((s,e)=>{const i=this.discovery.schemas(s);this.scimJson(e,200,{schemas:["urn:ietf:params:scim:api:messages:2.0:ListResponse"],totalResults:i.length,Resources:i,startIndex:1,itemsPerPage:i.length})})),s.get(`${e}/Schemas/:id`,h((s,e)=>{const i=this.discovery.schema(decodeURIComponent(s.params.id),s);if(!i)return l(e,404,"Schema not found");this.scimJson(e,200,i)}))}parseListQuery(s){const e=s.query,toInt=s=>{if("string"!=typeof s)return;const e=parseInt(s,10);return Number.isFinite(e)?e:void 0},toCsv=s=>{if("string"==typeof s)return s.split(",").map(s=>s.trim()).filter(Boolean)},i="string"==typeof e.sortOrder?e.sortOrder:void 0;return{filter:"string"==typeof e.filter?e.filter:void 0,startIndex:toInt(e.startIndex),count:toInt(e.count),attributes:toCsv(e.attributes),excludedAttributes:toCsv(e.excludedAttributes),sortBy:"string"==typeof e.sortBy?e.sortBy:void 0,sortOrder:"ascending"===i||"descending"===i?i:void 0}}getConfigApiData(){const s=this.scimPrefix,i=["User.json","Group.json","default-mapping.json"].map(s=>e(this.scimSchemaDir,s));let t;for(const s of a(this.server)){const[e,o,r]=i.map(e=>s.schemaUrlOf(e));if(e&&o){t={user:e,group:o,...r?{defaultMapping:r}:{}};break}}const o=this.users.userMapping,r=this.groups.groupMapping,c=o.entries.map(s=>s.scim).concat(["id","active"]),n=r.entries.map(s=>s.scim).concat(["id"]);return{enabled:!0,version:"2.0",prefix:s,endpoints:{users:`${s}/Users`,user:`${s}/Users/:id`,groups:`${s}/Groups`,group:`${s}/Groups/:id`,bulk:`${s}/Bulk`,serviceProviderConfig:`${s}/ServiceProviderConfig`,resourceTypes:`${s}/ResourceTypes`,schemas:`${s}/Schemas`},schemaUrls:t,capabilities:{patch:!0,bulk:!0,filter:!0,sort:!0,etag:Boolean(this.config.scim_etag),changePassword:!1,maxResults:this.config.scim_max_results||200,maxScanned:this.config.scim_max_scanned||1e4,bulkMaxOperations:this.config.scim_bulk_max_operations||100,bulkMaxPayloadSize:this.config.scim_bulk_max_payload_size||1048576},resourceTypes:[{id:"User",endpoint:`${s}/Users`,schema:"urn:ietf:params:scim:schemas:core:2.0:User",rdnAttribute:this.config.scim_user_rdn_attribute||"uid",objectClass:this.config.scim_user_object_class,filterableAttributes:c,mapping:o.entries},{id:"Group",endpoint:`${s}/Groups`,schema:"urn:ietf:params:scim:schemas:core:2.0:Group",rdnAttribute:this.config.scim_group_rdn_attribute||"cn",objectClass:this.config.scim_group_object_class,filterableAttributes:n,mapping:r.entries}],idStrategy:this.config.scim_id_attribute||"rdn",baseResolution:{userBaseTemplate:this.config.scim_user_base_template||void 0,groupBaseTemplate:this.config.scim_group_base_template||void 0,hasBaseMap:Boolean(this.config.scim_base_map)}}}}export{Scim as default,scimBodyParser};
1
+ import{fileURLToPath as s}from"url";import{join as i,dirname as e}from"path";import t from"../../abstract/plugin.js";import{setChangeSource as r}from"../../lib/changeContext.js";import{jsonBodyParser as o}from"../../lib/rawBody.js";import{staticPlugins as a}from"../../lib/staticFiles.js";import{BaseResolver as c}from"./baseResolver.js";import{ScimUsers as n}from"./users.js";import{ScimGroups as p}from"./groups.js";import{ScimBulk as m}from"./bulk.js";import{ScimDiscovery as u}from"./discovery.js";import{SCIM_CONTENT_TYPE as h,scimAsyncHandler as d,writeScimError as l}from"./errors.js";export{writeScimErrorFromException}from"./errors.js";import{projectResource as g,assertProjection as f,projectList as y}from"./projection.js";function scimBodyParser(s){return o({type:["application/json","application/scim+json"],limit:`${s.scim_bulk_max_payload_size||1048576}b`})}class Scim extends t{constructor(t){super(t),this.name="scim",this.roles=["api","configurable"],this.ldap=t.ldap,this.scimPrefix=this.config.scim_prefix||"/scim/v2",this.baseResolver=new c(this.config),this.users=new n({ldap:this.ldap,config:this.config,logger:this.logger,baseResolver:this.baseResolver,hooks:this.registeredHooks}),this.groups=new p({ldap:this.ldap,config:this.config,logger:this.logger,baseResolver:this.baseResolver,users:this.users,hooks:this.registeredHooks}),this.bulk=new m({config:this.config,logger:this.logger,users:this.users,groups:this.groups,scimPrefix:this.scimPrefix,hooks:this.registeredHooks});const r=this.config.schemas_path||i(e(s(import.meta.url)),"..","..","..","static","schemas");this.scimSchemaDir=i(r,"scim"),this.discovery=new u({config:this.config,schemaDir:this.scimSchemaDir,scimPrefix:this.scimPrefix,loadedPlugins:t.loadedPlugins})}scimJson(s,i,e){s.status(i).type(h).json(e)}scimResource(s,i,e,t,r={}){const o=e.id?`${this.scimPrefix}/${t}/${encodeURIComponent(e.id)}`:void 0,a=(this.config.scim_base_url||"")&&e.meta?.location?e.meta.location:o;a&&s.set("Location",a),this.scimJson(s,i,g(e,r))}parseProjection(s){const toCsv=s=>{if("string"!=typeof s)return;const i=s.split(",").map(s=>s.trim()).filter(Boolean);return i.length>0?i:void 0},i={attributes:toCsv(s.query.attributes),excludedAttributes:toCsv(s.query.excludedAttributes)};return f(i),i}readScimBody(s){const i=String(s.headers["content-type"]||"");if(i.startsWith("application/scim+json")||i.startsWith("application/json"))return s.body}api(s){const i=this.scimPrefix;this.server.registerBodyParser(i,scimBodyParser(this.config)),s.use(i,(s,i,e)=>{r(s,"scim"),e()}),s.use(i,d((s,i,e)=>{this.parseProjection(s),e()})),s.get(`${i}/Users`,d(async(s,i)=>{const e=this.parseListQuery(s);f(e);const t=await this.users.list(s,e);this.scimJson(i,200,y(t,e))})),s.get(`${i}/Users/:id`,d(async(s,i)=>{const e=this.parseProjection(s),t=await this.users.get(s,s.params.id);this.scimJson(i,200,g(t,e))})),s.post(`${i}/Users`,d(async(s,i)=>{const e=this.readScimBody(s);if(!e||"object"!=typeof e)return l(i,400,"Missing body","invalidSyntax");const t=await this.users.create(s,e);this.scimResource(i,201,t,"Users",this.parseProjection(s))})),s.put(`${i}/Users/:id`,d(async(s,i)=>{const e=this.readScimBody(s);if(!e||"object"!=typeof e)return l(i,400,"Missing body","invalidSyntax");const t=await this.users.replace(s,s.params.id,e);this.scimResource(i,200,t,"Users",this.parseProjection(s))})),s.patch(`${i}/Users/:id`,d(async(s,i)=>{const e=this.readScimBody(s);if(!e||"object"!=typeof e)return l(i,400,"Missing body","invalidSyntax");const t=await this.users.patch(s,s.params.id,e);this.scimResource(i,200,t,"Users",this.parseProjection(s))})),s.delete(`${i}/Users/:id`,d(async(s,i)=>{await this.users.delete(s,s.params.id),i.status(204).end()})),s.get(`${i}/Groups`,d(async(s,i)=>{const e=this.parseListQuery(s);f(e);const t=await this.groups.list(s,e);this.scimJson(i,200,y(t,e))})),s.get(`${i}/Groups/:id`,d(async(s,i)=>{const e=this.parseProjection(s),t=await this.groups.get(s,s.params.id);this.scimJson(i,200,g(t,e))})),s.post(`${i}/Groups`,d(async(s,i)=>{const e=this.readScimBody(s);if(!e||"object"!=typeof e)return l(i,400,"Missing body","invalidSyntax");const t=await this.groups.create(s,e);this.scimResource(i,201,t,"Groups",this.parseProjection(s))})),s.put(`${i}/Groups/:id`,d(async(s,i)=>{const e=this.readScimBody(s);if(!e||"object"!=typeof e)return l(i,400,"Missing body","invalidSyntax");const t=await this.groups.replace(s,s.params.id,e);this.scimResource(i,200,t,"Groups",this.parseProjection(s))})),s.patch(`${i}/Groups/:id`,d(async(s,i)=>{const e=this.readScimBody(s);if(!e||"object"!=typeof e)return l(i,400,"Missing body","invalidSyntax");const t=await this.groups.patch(s,s.params.id,e);this.scimResource(i,200,t,"Groups",this.parseProjection(s))})),s.delete(`${i}/Groups/:id`,d(async(s,i)=>{await this.groups.delete(s,s.params.id),i.status(204).end()})),s.post(`${i}/Bulk`,d(async(s,i)=>{const e=this.readScimBody(s);if(!e||"object"!=typeof e)return l(i,400,"Missing body","invalidSyntax");const t=this.config.scim_bulk_max_payload_size||1048576;let r;try{r=JSON.stringify(e).length}catch{return l(i,400,"Body nested too deeply","invalidValue")}if(r>t)return l(i,413,`Bulk payload too large: ${r} > ${t}`,"invalidValue");const o=await this.bulk.execute(s,e);this.scimJson(i,200,o)})),s.get(`${i}/ServiceProviderConfig`,d((s,i)=>{this.scimJson(i,200,this.discovery.serviceProviderConfig(s))})),s.get(`${i}/ResourceTypes`,d((s,i)=>{const e=this.discovery.resourceTypes(s);this.scimJson(i,200,{schemas:["urn:ietf:params:scim:api:messages:2.0:ListResponse"],totalResults:e.length,Resources:e,startIndex:1,itemsPerPage:e.length})})),s.get(`${i}/ResourceTypes/:name`,d((s,i)=>{const e=this.discovery.resourceType(s.params.name,s);if(!e)return l(i,404,"ResourceType not found");this.scimJson(i,200,e)})),s.get(`${i}/Schemas`,d((s,i)=>{const e=this.discovery.schemas(s);this.scimJson(i,200,{schemas:["urn:ietf:params:scim:api:messages:2.0:ListResponse"],totalResults:e.length,Resources:e,startIndex:1,itemsPerPage:e.length})})),s.get(`${i}/Schemas/:id`,d((s,i)=>{const e=this.discovery.schema(s.params.id,s);if(!e)return l(i,404,"Schema not found");this.scimJson(i,200,e)}))}parseListQuery(s){const i=s.query,toInt=s=>{if("string"!=typeof s)return;const i=parseInt(s,10);return Number.isFinite(i)?i:void 0},toCsv=s=>{if("string"==typeof s)return s.split(",").map(s=>s.trim()).filter(Boolean)},e="string"==typeof i.sortOrder?i.sortOrder:void 0;return{filter:"string"==typeof i.filter?i.filter:void 0,startIndex:toInt(i.startIndex),count:toInt(i.count),attributes:toCsv(i.attributes),excludedAttributes:toCsv(i.excludedAttributes),sortBy:"string"==typeof i.sortBy?i.sortBy:void 0,sortOrder:"ascending"===e||"descending"===e?e:void 0}}getConfigApiData(){const s=this.scimPrefix,e=["User.json","Group.json","default-mapping.json"].map(s=>i(this.scimSchemaDir,s));let t;for(const s of a(this.server)){const[i,r,o]=e.map(i=>s.schemaUrlOf(i));if(i&&r){t={user:i,group:r,...o?{defaultMapping:o}:{}};break}}const r=this.users.userMapping,o=this.groups.groupMapping,c=r.entries.map(s=>s.scim).concat(["id","active"]),n=o.entries.map(s=>s.scim).concat(["id"]);return{enabled:!0,version:"2.0",prefix:s,endpoints:{users:`${s}/Users`,user:`${s}/Users/:id`,groups:`${s}/Groups`,group:`${s}/Groups/:id`,bulk:`${s}/Bulk`,serviceProviderConfig:`${s}/ServiceProviderConfig`,resourceTypes:`${s}/ResourceTypes`,schemas:`${s}/Schemas`},schemaUrls:t,capabilities:{patch:!0,bulk:!0,filter:!0,sort:!0,etag:Boolean(this.config.scim_etag),changePassword:!1,maxResults:this.config.scim_max_results||200,maxScanned:this.config.scim_max_scanned||1e4,bulkMaxOperations:this.config.scim_bulk_max_operations||100,bulkMaxPayloadSize:this.config.scim_bulk_max_payload_size||1048576},resourceTypes:[{id:"User",endpoint:`${s}/Users`,schema:"urn:ietf:params:scim:schemas:core:2.0:User",rdnAttribute:this.config.scim_user_rdn_attribute||"uid",objectClass:this.config.scim_user_object_class,filterableAttributes:c,mapping:r.entries},{id:"Group",endpoint:`${s}/Groups`,schema:"urn:ietf:params:scim:schemas:core:2.0:Group",rdnAttribute:this.config.scim_group_rdn_attribute||"cn",objectClass:this.config.scim_group_object_class,filterableAttributes:n,mapping:o.entries}],idStrategy:this.config.scim_id_attribute||"rdn",baseResolution:{userBaseTemplate:this.config.scim_user_base_template||void 0,groupBaseTemplate:this.config.scim_group_base_template||void 0,hasBaseMap:Boolean(this.config.scim_base_map)}}}}export{Scim as default,scimBodyParser};
@@ -1 +1 @@
1
- import{escapeDnValue as t,launchHooksChained as e,validateDnValue as s,launchHooks as i,isChildOf as r,escapeLdapFilter as a}from"../../lib/utils.js";import{SCHEMA_LIST_RESPONSE as o}from"./types.js";import{resolveLockConfig as c,mergeMapping as h,loadMappingFile as n,DEFAULT_USER_MAPPING as l,requiredLdapAttributes as u,ldapToScimUser as d,scimUserToLdap as p,isLocked as m}from"./mapping.js";import{scimFilterToLdap as f}from"./filter.js";import{pagedSearch as b}from"./list.js";import{writePatch as g,patchToModifyRequest as w}from"./patch.js";import{scimNotFound as k,ScimError as y,scimInvalidValue as A,scimUniqueness as R}from"./errors.js";import{extractLdapCode as $}from"../../lib/ldapCodes.js";class ScimUsers{constructor(t){this.ldap=t.ldap,this.config=t.config,this.logger=t.logger,this.baseResolver=t.baseResolver,this.hooks=t.hooks,this.rdnAttribute=this.config.scim_user_rdn_attribute||"uid",this.objectClass=this.config.scim_user_object_class,this.idAttribute=this.config.scim_id_attribute||"rdn",this.maxResults=this.config.scim_max_results||200,this.maxScanned=this.config.scim_max_scanned||1e4,this.scimPrefix=this.config.scim_prefix||"/scim/v2";const e=c(this.config.scim_user_lock_attribute||"",this.config.scim_user_lock_value||"");this.lockAttribute=e.attribute,this.lockValue=e.value;const s=this.config.scim_user_mapping||"";this.mapping=h(l,s?n(s):void 0)}ctx(t){return{idAttribute:this.idAttribute,rdnAttribute:this.rdnAttribute,resourceType:"User",baseUrl:this.config.scim_base_url||(t?.protocol&&t.get?`${t.protocol}://${String(t.get("host")||"")}`:""),scimPrefix:this.scimPrefix,lockAttribute:this.lockAttribute,lockValue:this.lockValue}}ldapAttributes(){return u(this.mapping,[this.lockAttribute])}dnForId(e,s){const i=this.baseResolver.userBase(s);return`${this.rdnAttribute}=${t(e)},${i}`}async get(t,e){const s=this.dnForId(e,t);let i;try{i=await this.ldap.forRequest(t).search({paged:!1,scope:"base",attributes:this.ldapAttributes()},s)}catch(t){if(32===$(t))throw k(`User ${e} not found`);throw t}if(!i.searchEntries||0===i.searchEntries.length)throw k(`User ${e} not found`);return d(i.searchEntries[0],this.mapping,this.ctx(t))}async list(t,e){const s=this.baseResolver.userBase(t),i=Math.max(1,e.startIndex||1),r=Math.min(this.maxResults,Math.max(0,e.count??this.maxResults));let a,c=`(${this.objectClass.includes("inetOrgPerson")?"objectClass=inetOrgPerson":`objectClass=${this.objectClass[0]}`})`;if(e.filter){const t=f(e.filter,this.mapping,{lockAttribute:this.lockAttribute,lockValue:this.lockValue,supportsActive:!0});t.idEquals?a=t.idEquals:c=`(&${c}${t.ldapFilter})`}if(a)try{const e=await this.get(t,a);return{schemas:[o],totalResults:1,startIndex:1,itemsPerPage:1,Resources:[e]}}catch(t){if(t instanceof y&&404===t.statusCode)return{schemas:[o],totalResults:0,startIndex:1,itemsPerPage:0,Resources:[]};throw t}const{entries:h,totalResults:n}=await b({directory:this.ldap.forRequest(t),base:s,filter:c,attributes:this.ldapAttributes(),startIndex:i,count:r,maxScanned:this.maxScanned});e.sortBy,e.sortOrder;const l=h.map(e=>d(e,this.mapping,this.ctx(t)));return{schemas:[o],totalResults:n,startIndex:i,itemsPerPage:l.length,Resources:l}}async create(r,a){if(!a.userName)throw A("userName is required");const o=(await e(this.hooks.scimusercreate,[a,r]))[0],{rdn:c,attributes:h}=p(o,this.mapping,this.ctx(r),this.objectClass);if(!c)throw A("userName is required");s(c,this.rdnAttribute),h[this.rdnAttribute]=c;const n=this.baseResolver.userBase(r),l=`${this.rdnAttribute}=${t(c)},${n}`;try{await this.ldap.forRequest(r).add(l,h)}catch(t){if(68===$(t))throw R(`User ${c} already exists`);if(void 0!==h[this.lockAttribute])throw this.lockSchemaError(t);throw t}const u=await this.get(r,c);return i(this.hooks.scimusercreatedone,u),u}async replace(t,s,r){const a=this.dnForId(s,t);let o;try{o=await this.ldap.forRequest(t).search({paged:!1,scope:"base",attributes:this.ldapAttributes()},a)}catch(t){if(32===$(t))throw k(`User ${s} not found`);throw t}if(!o.searchEntries?.length)throw k(`User ${s} not found`);const c=o.searchEntries[0],h=(await e(this.hooks.scimuserupdate,[s,r,t]))[1],{attributes:n,active:l}=p(h,this.mapping,this.ctx(t),this.objectClass),u={replace:{},delete:[]},d=new Set(["objectClass","entryUUID","createTimestamp","modifyTimestamp",this.rdnAttribute,this.lockAttribute]),hasAttrValue=t=>null!=t&&("string"==typeof t?t.length>0:!Array.isArray(t)||t.length>0&&t.some(t=>null!=t));for(const t of this.ldapAttributes())d.has(t)||(null!=n[t]?u.replace[t]=n[t]:hasAttrValue(c[t])&&u.delete.push(t));if(!1===l?u.replace[this.lockAttribute]=this.lockValue:!0===l&&m(c,{lockAttribute:this.lockAttribute,lockValue:this.lockValue})&&u.delete.push(this.lockAttribute),0===Object.keys(u.replace||{}).length&&delete u.replace,0===u.delete.length&&delete u.delete,u.replace||u.delete)try{await this.ldap.forRequest(t).modify(a,u)}catch(t){if(void 0!==l)throw this.lockSchemaError(t);if(16!==$(t))throw t;throw new y(409,`${a} changed while this PUT was being applied; nothing was written. Retry the request.`)}const f=await this.get(t,s);return i(this.hooks.scimuserupdatedone,s,f),f}async patch(t,e,s){const r=this.dnForId(e,t);await g(r,async()=>{const i=await this.currentEntry(t,e),a=await w(s,{mapping:this.mapping,lockAttribute:this.lockAttribute,lockValue:this.lockValue,supportsActive:!0,current:i});try{await this.ldap.forRequest(t).modify(r,a,{permissive:!0})}catch(t){if(function(t,e){const s=e.toLowerCase(),i=t.delete;return[...Object.keys(t.replace||{}),...Object.keys(t.add||{}),...Array.isArray(i)?i:Object.keys(i||{})].some(t=>t.toLowerCase()===s)}(a,this.lockAttribute))throw this.lockSchemaError(t);throw t}});const a=await this.get(t,e);return i(this.hooks.scimuserupdatedone,e,a),a}lockSchemaError(t){const e=$(t);if(17!==e&&65!==e)return t;const s=t instanceof Error?t.message:String(t);return A(`The directory rejected '${this.lockAttribute}', the attribute backing 'active': ${s}. Point --scim-user-lock-attribute and --scim-user-lock-value at an attribute this directory defines (nsAccountLock / TRUE on 389-ds); the default needs the ppolicy overlay.`)}async currentEntry(t,e){const s=this.dnForId(e,t);let i;try{i=await this.ldap.forRequest(t).search({paged:!1,scope:"base",attributes:this.ldapAttributes()},s)}catch(t){if(32===$(t))throw k(`User ${e} not found`);throw t}if(!i.searchEntries?.length)throw k(`User ${e} not found`);return i.searchEntries[0]}async delete(t,s){await this.get(t,s);const r=(await e(this.hooks.scimuserdelete,[s,t]))[0],a=this.dnForId(r,t);await this.ldap.forRequest(t).delete(a),i(this.hooks.scimuserdeletedone,r)}async resolveRef(e,s){if(!s)return;const i=this.baseResolver.userBase(e),o=s.includes("=")&&s.includes(",");if(o&&!r(s,i))return;const c=o?s:`${this.rdnAttribute}=${t(s)},${i}`;try{const t=await this.ldap.forRequest(e).search({paged:!1,scope:"base",attributes:["dn"]},c);if(t.searchEntries&&t.searchEntries.length>0)return t.searchEntries[0].dn}catch(t){if(32!==$(t))throw t}if(!o)try{const t=await this.ldap.forRequest(e).search({filter:`(${this.rdnAttribute}=${a(s)})`,scope:"sub",paged:!1,attributes:["dn"]},i);if(t.searchEntries&&t.searchEntries.length>0)return t.searchEntries[0].dn}catch(t){if(32!==$(t))throw t}}get userMapping(){return this.mapping}}export{ScimUsers};
1
+ import{escapeDnValue as t,launchHooksChained as e,validateDnValue as s,launchHooks as i,isChildOf as r,escapeLdapFilter as a}from"../../lib/utils.js";import{SCHEMA_LIST_RESPONSE as o}from"./types.js";import{resolveLockConfig as c,mergeMapping as h,loadMappingFile as n,DEFAULT_USER_MAPPING as l,requiredLdapAttributes as u,ldapToScimUser as d,scimUserToLdap as p,isLocked as m}from"./mapping.js";import{scimFilterToLdap as b}from"./filter.js";import{pagedSearch as f}from"./list.js";import{writePatch as g,patchToModifyRequest as w}from"./patch.js";import{scimNotFound as k,ScimError as y,scimInvalidValue as A,scimUniqueness as R,kindOf as $}from"./errors.js";import{extractLdapCode as x}from"../../lib/ldapCodes.js";class ScimUsers{constructor(t){this.ldap=t.ldap,this.config=t.config,this.logger=t.logger,this.baseResolver=t.baseResolver,this.hooks=t.hooks,this.rdnAttribute=this.config.scim_user_rdn_attribute||"uid",this.objectClass=this.config.scim_user_object_class,this.idAttribute=this.config.scim_id_attribute||"rdn",this.maxResults=this.config.scim_max_results||200,this.maxScanned=this.config.scim_max_scanned||1e4,this.scimPrefix=this.config.scim_prefix||"/scim/v2";const e=c(this.config.scim_user_lock_attribute||"",this.config.scim_user_lock_value||"");this.lockAttribute=e.attribute,this.lockValue=e.value;const s=this.config.scim_user_mapping||"";this.mapping=h(l,s?n(s):void 0)}ctx(t){return{idAttribute:this.idAttribute,rdnAttribute:this.rdnAttribute,resourceType:"User",baseUrl:this.config.scim_base_url||(t?.protocol&&t.get?`${t.protocol}://${String(t.get("host")||"")}`:""),scimPrefix:this.scimPrefix,lockAttribute:this.lockAttribute,lockValue:this.lockValue}}ldapAttributes(){return u(this.mapping,[this.lockAttribute])}dnForId(e,s){const i=this.baseResolver.userBase(s);return`${this.rdnAttribute}=${t(e)},${i}`}async get(t,e){const s=this.dnForId(e,t);let i;try{i=await this.ldap.forRequest(t).search({paged:!1,scope:"base",attributes:this.ldapAttributes()},s)}catch(t){if(32===x(t))throw k(`User ${e} not found`);throw t}if(!i.searchEntries||0===i.searchEntries.length)throw k(`User ${e} not found`);return d(i.searchEntries[0],this.mapping,this.ctx(t))}async list(t,e){const s=this.baseResolver.userBase(t),i=Math.max(1,e.startIndex||1),r=Math.min(this.maxResults,Math.max(0,e.count??this.maxResults));let a,c=`(${this.objectClass.includes("inetOrgPerson")?"objectClass=inetOrgPerson":`objectClass=${this.objectClass[0]}`})`;if(e.filter){const t=b(e.filter,this.mapping,{lockAttribute:this.lockAttribute,lockValue:this.lockValue,supportsActive:!0});t.idEquals?a=t.idEquals:c=`(&${c}${t.ldapFilter})`}if(a)try{const e=await this.get(t,a);return{schemas:[o],totalResults:1,startIndex:1,itemsPerPage:1,Resources:[e]}}catch(t){if(t instanceof y&&404===t.statusCode)return{schemas:[o],totalResults:0,startIndex:1,itemsPerPage:0,Resources:[]};throw t}const{entries:h,totalResults:n}=await f({directory:this.ldap.forRequest(t),base:s,filter:c,attributes:this.ldapAttributes(),startIndex:i,count:r,maxScanned:this.maxScanned});e.sortBy,e.sortOrder;const l=h.map(e=>d(e,this.mapping,this.ctx(t)));return{schemas:[o],totalResults:n,startIndex:i,itemsPerPage:l.length,Resources:l}}async create(r,a){if(!a.userName)throw A("userName is required");p(a,this.mapping,this.ctx(r),this.objectClass);const o=(await e(this.hooks.scimusercreate,[a,r]))[0],{rdn:c,attributes:h}=p(o,this.mapping,this.ctx(r),this.objectClass);if(!c)throw A("userName is required");s(c,this.rdnAttribute),h[this.rdnAttribute]=c;const n=this.baseResolver.userBase(r),l=`${this.rdnAttribute}=${t(c)},${n}`;try{await this.ldap.forRequest(r).add(l,h)}catch(t){if(68===x(t))throw R(`User ${c} already exists`);if(void 0!==h[this.lockAttribute])throw this.lockSchemaError(t);throw t}const u=await this.get(r,c);return i(this.hooks.scimusercreatedone,u),u}async replace(t,s,r){const a=this.dnForId(s,t);let o;try{o=await this.ldap.forRequest(t).search({paged:!1,scope:"base",attributes:this.ldapAttributes()},a)}catch(t){if(32===x(t))throw k(`User ${s} not found`);throw t}if(!o.searchEntries?.length)throw k(`User ${s} not found`);const c=o.searchEntries[0],h=(await e(this.hooks.scimuserupdate,[s,r,t]))[1],{attributes:n,active:l}=p(h,this.mapping,this.ctx(t),this.objectClass),u={replace:{},delete:[]},d=new Set(["objectClass","entryUUID","createTimestamp","modifyTimestamp",this.rdnAttribute,this.lockAttribute]),hasAttrValue=t=>null!=t&&("string"==typeof t?t.length>0:!Array.isArray(t)||t.length>0&&t.some(t=>null!=t));for(const t of this.ldapAttributes())d.has(t)||(null!=n[t]?u.replace[t]=n[t]:hasAttrValue(c[t])&&u.delete.push(t));if(!1===l?u.replace[this.lockAttribute]=this.lockValue:!0===l&&m(c,{lockAttribute:this.lockAttribute,lockValue:this.lockValue})&&u.delete.push(this.lockAttribute),0===Object.keys(u.replace||{}).length&&delete u.replace,0===u.delete.length&&delete u.delete,u.replace||u.delete)try{await this.ldap.forRequest(t).modify(a,u)}catch(t){if(void 0!==l)throw this.lockSchemaError(t);if(16!==x(t))throw t;throw new y(409,`${a} changed while this PUT was being applied; nothing was written. Retry the request.`)}const b=await this.get(t,s);return i(this.hooks.scimuserupdatedone,s,b),b}async patch(t,e,s){const r=this.dnForId(e,t);await g(r,async()=>{const i=await this.currentEntry(t,e),a=await w(s,{mapping:this.mapping,lockAttribute:this.lockAttribute,lockValue:this.lockValue,supportsActive:!0,current:i});try{await this.ldap.forRequest(t).modify(r,a,{permissive:!0})}catch(t){if(function(t,e){const s=e.toLowerCase(),i=t.delete;return[...Object.keys(t.replace||{}),...Object.keys(t.add||{}),...Array.isArray(i)?i:Object.keys(i||{})].some(t=>t.toLowerCase()===s)}(a,this.lockAttribute))throw this.lockSchemaError(t);throw t}});const a=await this.get(t,e);return i(this.hooks.scimuserupdatedone,e,a),a}lockSchemaError(t){const e=x(t);if(17!==e&&65!==e)return t;const s=t instanceof Error?t.message:String(t);return A(`The directory rejected '${this.lockAttribute}', the attribute backing 'active': ${s}. Point --scim-user-lock-attribute and --scim-user-lock-value at an attribute this directory defines (nsAccountLock / TRUE on 389-ds); the default needs the ppolicy overlay.`)}async currentEntry(t,e){const s=this.dnForId(e,t);let i;try{i=await this.ldap.forRequest(t).search({paged:!1,scope:"base",attributes:this.ldapAttributes()},s)}catch(t){if(32===x(t))throw k(`User ${e} not found`);throw t}if(!i.searchEntries?.length)throw k(`User ${e} not found`);return i.searchEntries[0]}async delete(t,s){await this.get(t,s);const r=(await e(this.hooks.scimuserdelete,[s,t]))[0],a=this.dnForId(r,t);await this.ldap.forRequest(t).delete(a),i(this.hooks.scimuserdeletedone,r)}async resolveRef(e,s){if("string"!=typeof s)throw A(`A member value must be a string, got ${$(s)}`);if(!s)return;const i=this.baseResolver.userBase(e),o=s.includes("=")&&s.includes(",");if(o&&!r(s,i))return;const c=o?s:`${this.rdnAttribute}=${t(s)},${i}`;try{const t=await this.ldap.forRequest(e).search({paged:!1,scope:"base",attributes:["dn"]},c);if(t.searchEntries&&t.searchEntries.length>0)return t.searchEntries[0].dn}catch(t){if(32!==x(t))throw t}if(!o)try{const t=await this.ldap.forRequest(e).search({filter:`(${this.rdnAttribute}=${a(s)})`,scope:"sub",paged:!1,attributes:["dn"]},i);if(t.searchEntries&&t.searchEntries.length>0)return t.searchEntries[0].dn}catch(t){if(32!==x(t))throw t}}get userMapping(){return this.mapping}}export{ScimUsers};
@@ -1 +1 @@
1
- import{TwakePlugin as t}from"../../abstract/twakePlugin.js";class Drive extends t{constructor(t){super(t,"twake_drive_webadmin_url","twake_drive_webadmin_token","twake_drive_concurrency"),this.name="drive",this.roles=["consistency"],this.dependencies={onLdapChange:"core/ldap/onChange"},this.hooks={onLdapMailChange:async(t,e,i)=>{const n=e?Array.isArray(e)?String(e[0]):String(e):null,a=i?Array.isArray(i)?String(i[0]):String(i):null;if(!n)return void this.logger.debug(`Skipping mail change for ${t}: oldmail is empty (mail attribute was added, not changed)`);if(!a)return void this.logger.debug(`Skipping mail change for ${t}: newmail is empty (mail attribute was deleted)`);const r=await this.getCozyDomain(t);r?await this.updateCozyInstance("onLdapMailChange",r,t,{Email:a}):this.logger.debug(`Skipping mail change for ${t}: no Cozy domain attribute (${this.cozyDomainAttr})`)},onLdapDisplayNameChange:async(t,e,i)=>{const n=await this.ldapGetAttributes(t,[this.cozyDomainAttr,this.displayNameAttr,"cn","givenName","sn"]);if(!n)return void this.logger.warn(`Cannot update Cozy display name: entry not found for ${t}`);const a=this.attributeToString(n[this.cozyDomainAttr]);if(!a)return void this.logger.debug(`Skipping display name change for ${t}: no Cozy domain attribute (${this.cozyDomainAttr})`);const r=this.getDisplayNameFromAttributes(n);r?await this.updateCozyInstance("onLdapDisplayNameChange",a,t,{PublicName:r}):this.logger.warn(`Cannot update Cozy display name: no display name found for ${t}`)},onLdapDriveQuotaChange:async(t,e,i)=>{if(null===i)return void this.logger.debug(`Skipping drive quota change for ${t}: quota was deleted`);const n=await this.getCozyDomain(t);n?await this.updateCozyInstance("onLdapDriveQuotaChange",n,t,{DiskQuota:String(i)}):this.logger.debug(`Skipping drive quota change for ${t}: no Twake Drive domain attribute (${this.cozyDomainAttr})`)}},this.cozyDomainAttr=this.config.twake_drive_domain_attribute||"twakeCozyDomain",this.defaultDomainTemplate=this.config.twake_drive_default_domain_template||"",this.templateAttrs=this.defaultDomainTemplate?[...this.defaultDomainTemplate.matchAll(/\{(\w+)\}/g)].map(t=>t[1]):[],this.driveQuotaAttr=this.config.drive_quota_attribute||"twakeDriveQuota"}createHeaders(t){const e={};if(this.webadminToken){const t=Buffer.from(`:${this.webadminToken}`).toString("base64");e.Authorization=`Basic ${t}`}return t&&(e["Content-Type"]=t),e}isValidCozyDomain(t){if(!t||t.length>253)return!1;if(t.includes("/")||t.includes("\\")||t.includes("?")||t.includes("#"))return!1;return/^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/.test(t)}async getCozyDomain(t){const e=[this.cozyDomainAttr,...this.templateAttrs],i=await this.ldapGetAttributes(t,e),n=i?this.attributeToString(i[this.cozyDomainAttr]):null;if(n)return n;if(this.defaultDomainTemplate&&i){let t=this.defaultDomainTemplate;for(const e of this.templateAttrs){const n=this.attributeToString(i[e]);if(!n)return null;t=t.replace(`{${e}}`,n)}return t}return null}async updateCozyInstance(t,e,i,n){if(!this.isValidCozyDomain(e))return void this.logger.error({plugin:this.name,event:t,result:"error",dn:i,cozyDomain:e,error:"Invalid Cozy domain format (potential URL injection attempt)"});const a=new URL(`${this.webadminUrl}/instances/${e}`);a.searchParams.set("FromCloudery","true");for(const[t,e]of Object.entries(n))a.searchParams.set(t,e);return this.requestLimit(async()=>{const r={plugin:this.name,event:t,result:"error",dn:i,cozyDomain:e,...n};try{const t=await this.fetch(a.toString(),{method:"PATCH",headers:this.createHeaders("application/json")});t.ok?this.logger.info({...r,result:"success",http_status:t.status}):404===t.status?this.logger.debug({...r,result:"ignored",http_status:t.status,http_status_text:t.statusText,message:"Cozy instance not found (may not be provisioned yet)"}):this.logger.error({...r,http_status:t.status,http_status_text:t.statusText,url:a.toString()})}catch(t){this.logger.error({...r,error:t,url:a.toString()})}})}getDisplayNameFromAttributes(t){const e=this.attributeToString(t[this.displayNameAttr]);if(e)return e;const i=this.attributeToString(t.cn);if(i)return i;const n=this.attributeToString(t.givenName),a=this.attributeToString(t.sn);if(n||a){const t=[];return n&&t.push(n),a&&t.push(a),t.join(" ")}return null}async getDisplayNameFromDN(t){const e=[this.displayNameAttr,"cn","givenName","sn"],i=await this.ldapGetAttributes(t,e);return i?this.getDisplayNameFromAttributes(i):null}async getMailFromDN(t){const e=await this.ldapGetAttributes(t,[this.mailAttr]);return e?this.attributeToString(e[this.mailAttr]):null}async getDriveQuotaFromDN(t){const e=await this.ldapGetAttributes(t,[this.driveQuotaAttr]);if(!e)return null;const i=this.attributeToString(e[this.driveQuotaAttr]);return i?Number(i):null}async syncUserToCozy(t){const e=await this.ldapGetAttributes(t,[this.cozyDomainAttr,this.mailAttr,this.displayNameAttr,this.driveQuotaAttr,"cn","givenName","sn"]);if(!e)return this.logger.error(`Cannot sync user: entry not found for ${t}`),!1;const i=this.attributeToString(e[this.cozyDomainAttr]);if(!i)return this.logger.error(`Cannot sync user: no Twake Drive domain attribute for ${t}`),!1;const n=this.attributeToString(e[this.mailAttr]),a=this.getDisplayNameFromAttributes(e),r=this.attributeToString(e[this.driveQuotaAttr]),s={};return n&&(s.Email=n),a&&(s.PublicName=a),r&&(s.DiskQuota=r),0===Object.keys(s).length?(this.logger.warn(`No attributes to sync for ${t}`),!1):(await this.updateCozyInstance("syncUserToCozy",i,t,s),!0)}async blockInstance(t,e){const i=await this.getCozyDomain(t);if(!i)return this.logger.error(`Cannot block instance: no Cozy domain for ${t}`),!1;const n={Blocked:"true"};return e&&(n.BlockingReason=e),await this.updateCozyInstance("blockInstance",i,t,n),!0}async unblockInstance(t){const e=await this.getCozyDomain(t);return e?(await this.updateCozyInstance("unblockInstance",e,t,{Blocked:"false"}),!0):(this.logger.error(`Cannot unblock instance: no Cozy domain for ${t}`),!1)}async deleteInstance(t){const e=await this.getCozyDomain(t);if(!e)return this.logger.error(`Cannot delete instance: no Cozy domain for ${t}`),!1;if(!this.isValidCozyDomain(e))return this.logger.error({plugin:this.name,event:"deleteInstance",result:"error",dn:t,cozyDomain:e,error:"Invalid Cozy domain format (potential URL injection attempt)"}),!1;const i=`${this.webadminUrl}/instances/${e}`;return this.requestLimit(async()=>{try{const n=await this.fetch(i,{method:"DELETE",headers:this.createHeaders()});return n.ok?(this.logger.info({plugin:this.name,event:"deleteInstance",result:"success",dn:t,cozyDomain:e,http_status:n.status}),!0):404===n.status?(this.logger.info({plugin:this.name,event:"deleteInstance",result:"ignored",dn:t,cozyDomain:e,http_status:404,message:"Cozy instance not found (already deleted or never created)"}),!0):(this.logger.error({plugin:this.name,event:"deleteInstance",result:"error",dn:t,cozyDomain:e,http_status:n.status,http_status_text:n.statusText,url:i}),!1)}catch(n){return this.logger.error({plugin:this.name,event:"deleteInstance",result:"error",dn:t,cozyDomain:e,error:n,url:i}),!1}})}}export{Drive as default};
1
+ import{TwakePlugin as t}from"../../abstract/twakePlugin.js";class Drive extends t{constructor(t){super(t,"twake_drive_webadmin_url","twake_drive_webadmin_token","twake_drive_concurrency"),this.name="drive",this.roles=["consistency"],this.dependencies={onLdapChange:"core/ldap/onChange"},this.hooks={onLdapMailChange:async(t,e,i)=>{const n=e?Array.isArray(e)?String(e[0]):String(e):null,a=i?Array.isArray(i)?String(i[0]):String(i):null;if(!n)return void this.logger.debug(`Skipping mail change for ${t}: oldmail is empty (mail attribute was added, not changed)`);if(!a)return void this.logger.debug(`Skipping mail change for ${t}: newmail is empty (mail attribute was deleted)`);const r=await this.getCozyDomain(t);r?await this.updateCozyInstance("onLdapMailChange",r,t,{Email:a}):this.logger.debug(`Skipping mail change for ${t}: no Cozy domain attribute (${this.cozyDomainAttr})`)},onLdapDisplayNameChange:async(t,e,i)=>{const n=await this.ldapGetAttributes(t,[this.cozyDomainAttr,this.displayNameAttr,"cn","givenName","sn"]);if(!n)return void this.logger.warn(`Cannot update Cozy display name: entry not found for ${t}`);const a=this.attributeToString(n[this.cozyDomainAttr]);if(!a)return void this.logger.debug(`Skipping display name change for ${t}: no Cozy domain attribute (${this.cozyDomainAttr})`);const r=this.getDisplayNameFromAttributes(n);r?await this.updateCozyInstance("onLdapDisplayNameChange",a,t,{PublicName:r}):this.logger.warn(`Cannot update Cozy display name: no display name found for ${t}`)},onLdapDriveQuotaChange:async(t,e,i)=>{if(null===i)return void this.logger.debug(`Skipping drive quota change for ${t}: quota was deleted`);const n=await this.getCozyDomain(t);n?await this.updateCozyInstance("onLdapDriveQuotaChange",n,t,{DiskQuota:String(i)}):this.logger.debug(`Skipping drive quota change for ${t}: no Twake Drive domain attribute (${this.cozyDomainAttr})`)}},this.cozyDomainAttr=this.config.twake_drive_domain_attribute||"twakeCozyDomain",this.defaultDomainTemplate=this.config.twake_drive_default_domain_template||"",this.templateAttrs=this.defaultDomainTemplate?[...this.defaultDomainTemplate.matchAll(/\{(\w+)\}/g)].map(t=>t[1]):[],this.driveQuotaAttr=this.config.drive_quota_attribute||"twakeDriveQuota"}createHeaders(t){const e={};if(this.webadminToken){const t=Buffer.from(`:${this.webadminToken}`).toString("base64");e.Authorization=`Basic ${t}`}return t&&(e["Content-Type"]=t),e}isValidCozyDomain(t){if(!t||t.length>253)return!1;if(t.includes("/")||t.includes("\\")||t.includes("?")||t.includes("#"))return!1;return/^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/.test(t)}async getCozyDomain(t){const e=[this.cozyDomainAttr,...this.templateAttrs],i=await this.ldapGetAttributes(t,e),n=i?this.attributeToString(i[this.cozyDomainAttr]):null;if(n)return n;if(this.defaultDomainTemplate&&i){let t=this.defaultDomainTemplate;for(const e of this.templateAttrs){const n=this.attributeToString(i[e]);if(!n)return null;t=t.replace(`{${e}}`,()=>n)}return t}return null}async updateCozyInstance(t,e,i,n){if(!this.isValidCozyDomain(e))return void this.logger.error({plugin:this.name,event:t,result:"error",dn:i,cozyDomain:e,error:"Invalid Cozy domain format (potential URL injection attempt)"});const a=new URL(`${this.webadminUrl}/instances/${e}`);a.searchParams.set("FromCloudery","true");for(const[t,e]of Object.entries(n))a.searchParams.set(t,e);return this.requestLimit(async()=>{const r={plugin:this.name,event:t,result:"error",dn:i,cozyDomain:e,...n};try{const t=await this.fetch(a.toString(),{method:"PATCH",headers:this.createHeaders("application/json")});t.ok?this.logger.info({...r,result:"success",http_status:t.status}):404===t.status?this.logger.debug({...r,result:"ignored",http_status:t.status,http_status_text:t.statusText,message:"Cozy instance not found (may not be provisioned yet)"}):this.logger.error({...r,http_status:t.status,http_status_text:t.statusText,url:a.toString()})}catch(t){this.logger.error({...r,error:t,url:a.toString()})}})}getDisplayNameFromAttributes(t){const e=this.attributeToString(t[this.displayNameAttr]);if(e)return e;const i=this.attributeToString(t.cn);if(i)return i;const n=this.attributeToString(t.givenName),a=this.attributeToString(t.sn);if(n||a){const t=[];return n&&t.push(n),a&&t.push(a),t.join(" ")}return null}async getDisplayNameFromDN(t){const e=[this.displayNameAttr,"cn","givenName","sn"],i=await this.ldapGetAttributes(t,e);return i?this.getDisplayNameFromAttributes(i):null}async getMailFromDN(t){const e=await this.ldapGetAttributes(t,[this.mailAttr]);return e?this.attributeToString(e[this.mailAttr]):null}async getDriveQuotaFromDN(t){const e=await this.ldapGetAttributes(t,[this.driveQuotaAttr]);if(!e)return null;const i=this.attributeToString(e[this.driveQuotaAttr]);return i?Number(i):null}async syncUserToCozy(t){const e=await this.ldapGetAttributes(t,[this.cozyDomainAttr,this.mailAttr,this.displayNameAttr,this.driveQuotaAttr,"cn","givenName","sn"]);if(!e)return this.logger.error(`Cannot sync user: entry not found for ${t}`),!1;const i=this.attributeToString(e[this.cozyDomainAttr]);if(!i)return this.logger.error(`Cannot sync user: no Twake Drive domain attribute for ${t}`),!1;const n=this.attributeToString(e[this.mailAttr]),a=this.getDisplayNameFromAttributes(e),r=this.attributeToString(e[this.driveQuotaAttr]),s={};return n&&(s.Email=n),a&&(s.PublicName=a),r&&(s.DiskQuota=r),0===Object.keys(s).length?(this.logger.warn(`No attributes to sync for ${t}`),!1):(await this.updateCozyInstance("syncUserToCozy",i,t,s),!0)}async blockInstance(t,e){const i=await this.getCozyDomain(t);if(!i)return this.logger.error(`Cannot block instance: no Cozy domain for ${t}`),!1;const n={Blocked:"true"};return e&&(n.BlockingReason=e),await this.updateCozyInstance("blockInstance",i,t,n),!0}async unblockInstance(t){const e=await this.getCozyDomain(t);return e?(await this.updateCozyInstance("unblockInstance",e,t,{Blocked:"false"}),!0):(this.logger.error(`Cannot unblock instance: no Cozy domain for ${t}`),!1)}async deleteInstance(t){const e=await this.getCozyDomain(t);if(!e)return this.logger.error(`Cannot delete instance: no Cozy domain for ${t}`),!1;if(!this.isValidCozyDomain(e))return this.logger.error({plugin:this.name,event:"deleteInstance",result:"error",dn:t,cozyDomain:e,error:"Invalid Cozy domain format (potential URL injection attempt)"}),!1;const i=`${this.webadminUrl}/instances/${e}`;return this.requestLimit(async()=>{try{const n=await this.fetch(i,{method:"DELETE",headers:this.createHeaders()});return n.ok?(this.logger.info({plugin:this.name,event:"deleteInstance",result:"success",dn:t,cozyDomain:e,http_status:n.status}),!0):404===n.status?(this.logger.info({plugin:this.name,event:"deleteInstance",result:"ignored",dn:t,cozyDomain:e,http_status:404,message:"Cozy instance not found (already deleted or never created)"}),!0):(this.logger.error({plugin:this.name,event:"deleteInstance",result:"error",dn:t,cozyDomain:e,http_status:n.status,http_status_text:n.statusText,url:i}),!1)}catch(n){return this.logger.error({plugin:this.name,event:"deleteInstance",result:"error",dn:t,cozyDomain:e,error:n,url:i}),!1}})}}export{Drive as default};
@@ -1 +1 @@
1
- import{randomUUID as t}from"node:crypto";import{BadRequestError as e,AUTHZ_REFUSED as r,HttpError as s}from"../../lib/errors.js";import{extractLdapCode as i}from"../../lib/ldapCodes.js";import{parseDn as o,escapeDnValue as a,isDummyMemberDn as n,escapeLdapFilter as c,isAuthzRefusal as u,normalizeDn as d,unescapeDnValue as h}from"../../lib/utils.js";import p from"../ldap/groups.js";import{BaseResolver as m}from"../scim/baseResolver.js";import{mergeMapping as g,loadMappingFile as l,DEFAULT_GROUP_MAPPING as f}from"../scim/mapping.js";import{outsideRequest as y}from"../../lib/requestContext.js";import{valueOf as w,lifecycleAttributes as b,first as _}from"./lifecycleAttributes.js";const read=(t,e)=>_(w(t,e)),v="{org}",O=["displayName","description","createdAt"],$=["uid","displayName","mail","jobTitle"],k={uid:"uid",_id:"entryUUID",cn:"cn",sn:"sn",givenName:"givenName",displayName:"displayName",mail:"mail",mobile:"mobile",jobTitle:"twakeJobTitle",company:"twakeCompany",organizationRole:"twakeOrganizationRole",organizationId:"twakeOrganizationId"},I={familyName:"sn",givenName:"givenName",additionalName:"twakeAdditionalName",namePrefix:"twakeNamePrefix"},j="twakeIsTechnical";class RouteError extends Error{constructor(t,e,r){super(e),this.status=t,this.code=r}}const notFound=t=>new RouteError(404,`${t[0].toUpperCase()}${t.slice(1)} not found`,`${t.toUpperCase()}_NOT_FOUND`),invalid=(t,e="INVALID_INPUT")=>new RouteError(400,t,e),N="name must be a non-blank string of at most 256 characters with no control characters";function isDisplayName(t){return"string"==typeof t&&t.trim().length>0&&t.length<=256&&!/[\x00-\x1f\x7f]/.test(t)}const R=/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/,isColor=t=>"string"==typeof t&&R.test(t),text=t=>"string"==typeof t||"number"==typeof t?String(t):"";function bodyOf(t){const e=t.body;if(!e||"object"!=typeof e||Array.isArray(e))throw invalid("Request body must be a JSON object");return e}function values(t){return null==t?[]:(Array.isArray(t)?t:[t]).map(t=>t.toString())}function dnKey(t){try{return d(t)}catch{return t.toLowerCase()}}function parentOf(t){return o(t).slice(1).join(",")}function rdnValue(t){const[e]=o(t),r=e.indexOf("=");return r>0?h(e.slice(r+1).trim()):t}function branchPattern(t){const[e,r]=o(t).join(",").split(v).map(t=>t.replace(/[.*+?^${}()|[\]\\]/g,"\\$&"));return new RegExp(`^${e}(?<org>(?:\\\\.|[^,])+)${r}$`,"i")}function movedInto(t,e,r){const s=organizationIn(t,r);return void 0!==s&&dnKey(parentOf(r))!==dnKey(parentOf(e))?s:void 0}const P=["twakegroup","twakestaticgroup"],z=Symbol("whole member lists");function organizationIn(t,e){const r=t.exec(parentOf(o(e).join(",")));return r?.groups?h(r.groups.org):void 0}class TwakeGroups extends p{constructor(r){super(r),this.name="twakeGroups",this.hooks={ldapaddrequest:([t,e,r])=>{this.checkMembers(t,e.member);const s=this.organizationOf(t);return void 0!==s&&void 0!==w(e,this.displayName)&&void 0===w(e,this.createdAt)&&(e={...e,[this.createdAt]:(new Date).toISOString()}),void 0!==s&&(e=this.linked(t,s,e)),[t,e,r]},ldapmodifyrequest:([t,e,r,s])=>(this.checkMembers(t,e.add?.member),this.checkMembers(t,e.replace?.member),this.checkLinkChange(t,e),[t,e,r,s]),ldaprenamerequest:async([t,r,s])=>{const o=movedInto(this.groupPattern,t,r);if(void 0===o)return[t,r,s];let a;try{const{searchEntries:e}=await this.ldap.system.search({paged:!1,scope:"base",attributes:["member"],[z]:!0},t);a=e[0]?.member}catch(t){if(32!==i(t))throw t}if(void 0!==this.foreign(o,a))throw new e(`The group holds members of another organization than ${o}`);return[t,r,s]},ldaprenamedone:async([t,e])=>{const r=movedInto(this.groupPattern,t,e),s=this.config.ldap_organization_link_attribute;if(void 0===r||!s||!this.orgPattern||!this.judgedByAttachment())return;const{searchEntries:i}=await this.ldap.system.search({paged:!1,scope:"base",attributes:[s]},e);void 0!==w(i[0]??{},s)&&await this.ldap.system.modify(e,{replace:{[s]:this.orgPattern.replace(v,a(r))}})},ldapsearchfilter:async([t,e,r])=>[r?.[z]?t:await this.hideTombstones(t),e,r],scimgroupcreate:([e,r,s])=>(s??(s=new m(this.config).groupBase(r)),[this.groupPattern.test(o(s).join(","))?{...e,id:t()}:e,r,s])},this.groupBase=this.config.twake_group_base||"",this.userBase=this.config.twake_group_user_base||"";for(const[t,e]of[["--twake-group-base",this.groupBase],["--twake-group-user-base",this.userBase]])if(!e.includes(v))throw new Error(`${this.name}: ${t} must hold ${v}`);this.groupPattern=branchPattern(this.groupBase),this.attrs=b(this.config),this.orgPattern=this.config.twake_group_organization_dn||"",this.displayName=this.config.twake_group_display_name_attribute||"twakeDisplayName",this.color=this.config.twake_group_color_attribute||"twakeGroupColor",this.createdAt=this.config.twake_group_created_at_attribute||"twakeCreatedAt",this.maxPage=this.config.twake_group_max_page_limit||1e3,this.memberFields={...k,...this.config.twake_group_member_fields}}adaptSchema(t){const e={[this.displayName]:{type:"string",required:!0},[this.color]:{type:"string",test:R.source},[this.createdAt]:{type:"string"}},r=this.config.ldap_organization_link_attribute;r&&this.orgPattern&&(e[r]={type:"string",generated:!0});for(const[r,s]of Object.entries(e)){const e=Object.keys(t.attributes).find(t=>t.toLowerCase()===r.toLowerCase()),i=e?t.attributes[e]:s;e&&delete t.attributes[e],t.attributes[r]=i}return t}afterLoad(){if(!this.server.loadedPlugins.scim)return;const t=this.config.scim_group_rdn_attribute||"cn",e=this.config.scim_group_mapping||"",r=g(f,e?l(e):void 0).entries.find(e=>e.ldap?.toLowerCase()===t.toLowerCase());r&&this.logger.warn(`${this.name}: the SCIM group mapping writes ${r.scim} to ${t}, so SCIM groups keep it as their RDN instead of a generated cn: map ${r.scim} to another attribute with --scim-group-mapping`)}linked(t,r,s){const i=this.config.ldap_organization_link_attribute;if(!i||!this.orgPattern||!this.judgedByAttachment())return s;const o=this.orgPattern.replace(v,a(r)),n=read(s,i);if(void 0!==n){if(dnKey(n)!==dnKey(o))throw new e(`${t} lies in the branch of ${r}, and must be linked to it`);return s}const c=values(s.objectClass);return c.some(t=>P.includes(t.toLowerCase()))||c.push("twakeGroup"),{...s,objectClass:c,[i]:o}}checkLinkChange(t,r){const s=this.config.ldap_organization_link_attribute,i=this.organizationOf(t);if(void 0===i||!s||!this.orgPattern||!this.judgedByAttachment())return;const o=dnKey(this.orgPattern.replace(v,a(i))),named=t=>Object.keys(t??{}).filter(t=>t.toLowerCase()===s.toLowerCase()),n=[...named(r.add).flatMap(t=>values(r.add[t])),...named(r.replace).flatMap(t=>values(r.replace[t]))],c=Array.isArray(r.delete)?r.delete.some(t=>t.toLowerCase()===s.toLowerCase()):named(r.delete).length>0,u=named(r.replace).length>0&&0===n.length;if(c||u||n.some(t=>dnKey(t)!==o))throw new e(`${t} lies in the branch of ${i}, and must stay linked to it`)}judgedByAttachment(){return Object.values(this.server.loadedPlugins).some(t=>t.roles?.includes("authz")&&!0===t.judgesByAttachment?.())}organizationOf(t){return organizationIn(this.groupPattern,t)}groupBaseOf(t){return this.groupBase.replace(v,a(t))}userBaseOf(t){return this.userBase.replace(v,a(t))}checkMembers(t,r){const s=this.organizationOf(t);if(!s)return;const i=this.foreign(s,r);if(void 0!==i)throw new e(`${i} is not a user of organization ${s}`)}foreign(t,e){const r=dnKey(this.userBaseOf(t));return values(e).find(t=>!n(t,this.config.group_dummy_user)&&dnKey(parentOf(t))!==r)}async hideTombstones(t){if(!this.attrs.deleted)return t;const e=new Set;for(const r of t.searchEntries){const t=this.organizationOf(r.dn);t&&void 0!==r.member&&e.add(t)}if(0===e.size)return t;const r=new Set;for(const t of e)for(const e of await this.tombstonesOf(t))r.add(dnKey(e));return 0===r.size?t:{...t,searchEntries:t.searchEntries.map(t=>void 0!==t.member&&this.organizationOf(t.dn)?{...t,member:values(t.member).filter(t=>!r.has(dnKey(t)))}:t)}}async tombstonesOf(t){const{deleted:e,deletedValue:r}=this.attrs;if(!e)return[];try{const{searchEntries:s}=await this.ldap.system.search({paged:!1,scope:"one",filter:`(${e}=${c(r)})`,attributes:["dn"]},this.userBaseOf(t));return s.map(t=>t.dn)}catch(t){if(32===i(t))return[];throw t}}api(t){const e=`${this.config.api_prefix}/v1/organizations/:id/groups`,r=this.organizationRoute.bind(this);t.get(e,r("listing groups",(t,e,r)=>this.listRoute(t,e,r))),t.post(e,r("creating group",(t,e,r)=>this.createRoute(t,e,r))),t.get(`${e}/:groupId`,r("getting group",async(t,e,r)=>{const s=await this.readGroup(r,t.params.groupId,t);if(!s)throw notFound("group");e.json(s)})),t.get(`${e}/:groupId/members`,r("listing group members",(t,e,r)=>this.membersRoute(t,e,r))),t.patch(`${e}/:groupId`,r("updating group",(t,e,r)=>this.updateRoute(t,e,r))),t.delete(`${e}/:groupId`,r("deleting group",async(t,e,r)=>{await this.onGroup(()=>this.deleteGroup(this.groupDn(r,t.params.groupId),t)),e.json({success:!0})})),t.post(`${e}/:groupId/members`,r("adding group members",(t,e,r)=>this.addMembersRoute(t,e,r))),t.delete(`${e}/:groupId/members/:userId`,r("removing group member",(t,e,r)=>this.removeMemberRoute(t,e,r)))}organizationRoute(t,e,i=this.name){return async(o,a)=>{try{const t=o.params.id;await this.checkOrganization(t,o),await e(o,a,t)}catch(e){if(e instanceof RouteError)return void a.status(e.status).json({error:e.message,code:e.code});if(u(e))return void a.status(403).json({error:r,code:"REFUSED"});if(e instanceof s&&e.statusCode<500)return void a.status(e.statusCode).json({error:e.message,code:400===e.statusCode?"INVALID_INPUT":"REFUSED"});this.logger.error({plugin:i,context:t,error:String(e)}),a.status(500).json({error:`Error ${t}`,code:"INTERNAL_ERROR"})}}}groupDn(t,e){return`${this.cn}=${a(e)},${this.groupBaseOf(t)}`}get userAttribute(){return this.config.ldap_user_main_attribute||"uid"}userDn(t,e){return`${this.userAttribute}=${a(e)},${this.userBaseOf(t)}`}async checkOrganization(t,e){if(!this.orgPattern)return;const r=this.config.twake_group_organization_status_attribute||"twakeOrgStatus",s=this.orgPattern.replace(v,a(t));if(!await this.ldap.readableBy(s,e))throw notFound("organization");let o;try{o=(await this.ldap.system.search({paged:!1,scope:"base",attributes:[r]},s)).searchEntries[0]}catch(t){if(32!==i(t))throw t}if(!o)throw notFound("organization");const n=this.config.twake_group_organization_deleted_value||"deleted";if(read(o,r)===n)throw new RouteError(410,"Organization has been deleted","ORGANIZATION_DELETED")}page(t,e){const r=Math.max(1,parseInt(t.query.page)||1),s=Math.max(1,Math.min(parseInt(t.query.limit)||20,this.maxPage)),i=t.query.search||void 0;if(void 0!==i&&("string"!=typeof i||i.length<2))throw invalid("Search query must be at least 2 characters","INVALID_SEARCH_QUERY");const o=t.query.sortBy;if(void 0!==o&&!e.includes(o))throw invalid(`sortBy must be one of: ${e.join(", ")}`,"INVALID_SORT_FIELD");return{page:r,limit:s,offset:(r-1)*s,search:i,sortBy:o,desc:"desc"===t.query.sortOrder}}sorted(t,e,r){if(!e)return t;const s=r?-1:1;return t.sort((t,r)=>text(t[e]).localeCompare(text(r[e]))*s)}groupOf(t,e){const r=read(e,this.cn)||"",s=dnKey(this.userBaseOf(t)),i=values(e.member).filter(t=>!n(t,this.config.group_dummy_user)).map(t=>dnKey(parentOf(t))===s?rdnValue(t):t);return{id:r,cn:r,displayName:read(e,this.displayName)??r,description:read(e,"description"),color:read(e,this.color),organizationId:t,baseDN:this.groupDn(t,r),members:i,createdAt:read(e,this.createdAt)}}async groups(t,e,r,s){try{const{searchEntries:i}=await this.ldap.search({paged:!1,scope:"one",filter:e,...r&&{attributes:r}},this.groupBaseOf(t),s);return i}catch(t){if(32===i(t))return[];throw t}}async readGroup(t,e,r){try{const{searchEntries:s}=await this.ldap.search({paged:!1,scope:"base"},this.groupDn(t,e),r);return s[0]&&this.groupOf(t,s[0])}catch(t){if(32===i(t)||u(t))return;throw t}}async named(t,e){const[r]=await this.groups(t,`(${this.displayName}=${c(e)})`);return r&&read(r,this.cn)}async onGroup(t){try{return await t()}catch(t){if(32===i(t))throw notFound("group");throw t}}async listRoute(t,e,r){const s=this.page(t,O);let i=`(${this.cn}=*)`;if(s.search){const t=c(s.search);i=`(&${i}(|(${this.displayName}=*${t}*)(description=*${t}*)(&(!(${this.displayName}=*))(${this.cn}=*${t}*))))`}const o=this.sorted((await this.groups(r,i,void 0,t)).map(t=>this.groupOf(r,t)),s.sortBy,s.desc);e.json({organizationId:r,groups:o.slice(s.offset,s.offset+s.limit),pagination:{page:s.page,limit:s.limit,total:o.length,totalPages:Math.ceil(o.length/s.limit)}})}async createRoute(e,r,s){const i=bodyOf(e);if(!isDisplayName(i.name))throw invalid(N);if(void 0!==i.description&&"string"!=typeof i.description)throw invalid("description must be a string");if(void 0!==i.color&&!isColor(i.color))throw invalid("color must be a hex code (e.g. #RRGGBB or #RGB)");const o=t(),a={objectClass:this.config.group_class||["top","groupOfNames"],[this.displayName]:i.name,[this.createdAt]:(new Date).toISOString()};i.description&&(a.description=i.description),i.color&&(a[this.color]=i.color);const n=this.linked(this.groupDn(s,o),s,a);await this.ldap.authorize("ldapaddrequest",[this.groupDn(s,o),this.candidateGroup(o,[],n),e]);const c=i.name;if(await y(()=>this.named(s,c)))throw new RouteError(409,"Group already exists","GROUP_EXISTS");await this.addGroup(this.groupDn(s,o),[],n,e);const u=await y(()=>this.readGroup(s,o));r.status(201).json(u)}async updateRoute(t,e,r){const s=bodyOf(t),i=t.params.groupId,o=Object.keys(s).filter(t=>!["name","description","color"].includes(t));if(o.length)throw invalid(`Only "name", "description" and "color" can be updated; unsupported field(s): ${o.join(", ")}`);const{name:a,description:n,color:c}=s;if(void 0===a&&void 0===n&&void 0===c)throw invalid('at least one of "name", "description" or "color" must be provided');if(void 0!==a&&!isDisplayName(a))throw invalid(N);if(void 0!==n&&"string"!=typeof n)throw invalid("description must be a string");if(void 0!==c&&""!==c&&!isColor(c))throw invalid("color must be a hex code (e.g. #RRGGBB or #RGB) or empty to clear it");const u={};if(void 0!==a&&(u[this.displayName]=a),void 0!==n&&(u.description=n||[]),void 0!==c&&(u[this.color]=c||[]),await this.ldap.authorize("ldapmodifyrequest",[this.groupDn(r,i),{replace:u},this.opNumber(),t]),void 0!==a){const t=await y(()=>this.named(r,a));if(t&&t!==i)throw new RouteError(409,"Group already exists","GROUP_EXISTS")}await this.onGroup(()=>this.modifyGroup(this.groupDn(r,i),{replace:u},t)),e.json({success:!0})}async membersRoute(t,e,r){const s=this.page(t,$),i=t.params.groupId,o=await this.readGroup(r,i,t);if(!o)throw notFound("group");e.json({organizationId:r,id:i,...await this.memberPage(s,r,o.members,void 0,t)})}async memberPage(t,e,r,s=()=>({}),i){const o=[...new Map(r.map(t=>[t.toLowerCase(),t])).values()],a=await this.profiles(e,o,i);let n=o.map(t=>({...a.get(t.toLowerCase())??{uid:t},...s(t)}));if(t.search){const e=t.search.toLowerCase();n=n.filter(t=>[t.uid,t.displayName||t.cn,t.mail].some(t=>text(t).toLowerCase().includes(e)))}n=this.sorted(n,t.sortBy??"uid",t.desc);const c=Math.ceil(n.length/t.limit);return{members:n.slice(t.offset,t.offset+t.limit),pagination:{page:t.page,limit:t.limit,total:n.length,totalPages:c,hasNextPage:t.page<c,hasPreviousPage:t.page>1}}}async users(t,e,r,s){const{deleted:o,deletedValue:a}=this.attrs,n=o?`(!(${o}=${c(a)}))`:"",d=[];for(let o=0;o<e.length;o+=this.maxPage){const a=`(&(|${e.slice(o,o+this.maxPage).map(t=>`(${this.userAttribute}=${c(t)})`).join("")})${n})`;try{const{searchEntries:e}=await this.ldap.search({paged:!1,scope:"one",filter:a,attributes:r},this.userBaseOf(t),s);d.push(...e)}catch(t){if(32!==i(t)&&!u(t))throw t}}return d}async profiles(t,e,r){const s=[...new Set([...Object.values(this.memberFields),...Object.values(I),j])],i=new Map;for(const o of await this.users(t,e,s,r)){const t=read(o,this.userAttribute);if(!t)continue;const e={};for(const[t,r]of Object.entries(this.memberFields)){const s=read(o,r);void 0!==s&&(e[t]=s)}const r={};for(const[t,e]of Object.entries(I)){const s=read(o,e);void 0!==s&&(r[t]=s)}Object.keys(r).length&&(e.name=r),e.isTechnical="TRUE"===read(o,j)?.toUpperCase(),i.set(t.toLowerCase(),e)}return i}async addMembersRoute(t,e,r){const{usernames:s}=bodyOf(t);if(!Array.isArray(s)||0===s.length)throw invalid("usernames must be a non-empty array");if(!s.every(t=>"string"==typeof t&&t.length>0))throw invalid("usernames must be an array of non-empty strings");if(s.length>this.maxPage)throw invalid(`usernames cannot exceed ${this.maxPage} entries per request`);const i=t.params.groupId,o=[...new Map(s.map(t=>[t.toLowerCase(),t])).keys()];await this.ldap.authorize("ldapmodifyrequest",[this.groupDn(r,i),{add:{member:o.map(t=>this.userDn(r,t))}},this.opNumber(),t]);const a=await this.readGroup(r,i,t);if(!a)throw notFound("group");const n=new Map((await this.users(r,o,[this.userAttribute],t)).map(t=>{const e=read(t,this.userAttribute)||"";return[e.toLowerCase(),e]}));if(o.some(t=>!n.has(t)))throw notFound("user");const c=new Set(a.members.map(t=>t.toLowerCase())),u=o.filter(t=>!c.has(t)).map(t=>this.userDn(r,n.get(t))),d=this.groupDn(r,i);u.length&&(await this.onGroup(()=>this.ldap.modify(d,{add:{member:u}},t)),await this.dropPlaceholder(d,t)),e.json({success:!0})}async dropPlaceholder(t,e){const r=this.config.group_dummy_user;if(r)try{await this.ldap.modify(t,{delete:{member:r}},e)}catch(t){if(![16,65].includes(i(t)??0))throw t}}async removeMemberRoute(t,e,r){const s=this.groupDn(r,t.params.groupId),o=this.userDn(r,t.params.userId);try{await this.ldap.modify(s,{delete:{member:o}},t)}catch(e){const r=i(e);if(32===r)throw notFound("group");if(16===r)throw notFound("member");if(65!==r||!this.config.group_dummy_user)throw e;await this.ldap.modify(s,{replace:{member:[this.config.group_dummy_user]}},t)}e.json({success:!0})}}export{N as NAME_RULE,v as ORG,RouteError,bodyOf,branchPattern,TwakeGroups as default,dnKey,invalid,isDisplayName,movedInto,notFound,organizationIn,parentOf,rdnValue,read,values};
1
+ import{randomUUID as t}from"node:crypto";import{BadRequestError as e,AUTHZ_REFUSED as r,HttpError as s}from"../../lib/errors.js";import{extractLdapCode as i}from"../../lib/ldapCodes.js";import{parseDn as o,isDummyMemberDn as a,escapeLdapFilter as n,isAuthzRefusal as c,escapeDnValue as u,rdnValue as h,fillPattern as d,normalizeDn as p,unescapeDnValue as m}from"../../lib/utils.js";import g from"../ldap/groups.js";import{BaseResolver as l}from"../scim/baseResolver.js";import{mergeMapping as f,loadMappingFile as w,DEFAULT_GROUP_MAPPING as y}from"../scim/mapping.js";import{outsideRequest as b}from"../../lib/requestContext.js";import{valueOf as _,lifecycleAttributes as v,first as O}from"./lifecycleAttributes.js";const read=(t,e)=>O(_(t,e)),$="{org}";function withOrg(t,e){return d(t,$,u(e))}const k=["displayName","description","createdAt"],I=["uid","displayName","mail","jobTitle"],j={uid:"uid",_id:"entryUUID",cn:"cn",sn:"sn",givenName:"givenName",displayName:"displayName",mail:"mail",mobile:"mobile",jobTitle:"twakeJobTitle",company:"twakeCompany",organizationRole:"twakeOrganizationRole",organizationId:"twakeOrganizationId"},N={familyName:"sn",givenName:"givenName",additionalName:"twakeAdditionalName",namePrefix:"twakeNamePrefix"},R="twakeIsTechnical";class RouteError extends Error{constructor(t,e,r){super(e),this.status=t,this.code=r}}const notFound=t=>new RouteError(404,`${t[0].toUpperCase()}${t.slice(1)} not found`,`${t.toUpperCase()}_NOT_FOUND`),invalid=(t,e="INVALID_INPUT")=>new RouteError(400,t,e),P="name must be a non-blank string of at most 256 characters with no control characters";function isDisplayName(t){return"string"==typeof t&&t.trim().length>0&&t.length<=256&&!/[\x00-\x1f\x7f]/.test(t)}const z=/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/,isColor=t=>"string"==typeof t&&z.test(t),text=t=>"string"==typeof t||"number"==typeof t?String(t):"";function bodyOf(t){const e=t.body;if(!e||"object"!=typeof e||Array.isArray(e))throw invalid("Request body must be a JSON object");return e}function values(t){return null==t?[]:(Array.isArray(t)?t:[t]).map(t=>t.toString())}function dnKey(t){try{return p(t)}catch{return t.toLowerCase()}}function parentOf(t){return o(t).slice(1).join(",")}function branchPattern(t){const[e,r]=o(t).join(",").split($).map(t=>t.replace(/[.*+?^${}()|[\]\\]/g,"\\$&"));return new RegExp(`^${e}(?<org>(?:\\\\.|[^,])+)${r}$`,"i")}function movedInto(t,e,r){const s=organizationIn(t,r);return void 0!==s&&dnKey(parentOf(r))!==dnKey(parentOf(e))?s:void 0}const A=["twakegroup","twakestaticgroup"],C=Symbol("whole member lists");function organizationIn(t,e){const r=t.exec(parentOf(o(e).join(",")));return r?.groups?m(r.groups.org):void 0}class TwakeGroups extends g{constructor(r){super(r),this.name="twakeGroups",this.hooks={ldapaddrequest:([t,e,r])=>{this.checkMembers(t,e.member);const s=this.organizationOf(t);return void 0!==s&&void 0!==_(e,this.displayName)&&void 0===_(e,this.createdAt)&&(e={...e,[this.createdAt]:(new Date).toISOString()}),void 0!==s&&(e=this.linked(t,s,e)),[t,e,r]},ldapmodifyrequest:([t,e,r,s])=>(this.checkMembers(t,e.add?.member),this.checkMembers(t,e.replace?.member),this.checkLinkChange(t,e),[t,e,r,s]),ldaprenamerequest:async([t,r,s])=>{const o=movedInto(this.groupPattern,t,r);if(void 0===o)return[t,r,s];let a;try{const{searchEntries:e}=await this.ldap.system.search({paged:!1,scope:"base",attributes:["member"],[C]:!0},t);a=e[0]?.member}catch(t){if(32!==i(t))throw t}if(void 0!==this.foreign(o,a))throw new e(`The group holds members of another organization than ${o}`);return[t,r,s]},ldaprenamedone:async([t,e])=>{const r=movedInto(this.groupPattern,t,e),s=this.config.ldap_organization_link_attribute;if(void 0===r||!s||!this.orgPattern||!this.judgedByAttachment())return;const{searchEntries:i}=await this.ldap.system.search({paged:!1,scope:"base",attributes:[s]},e);void 0!==_(i[0]??{},s)&&await this.ldap.system.modify(e,{replace:{[s]:withOrg(this.orgPattern,r)}})},ldapsearchfilter:async([t,e,r])=>[r?.[C]?t:await this.hideTombstones(t),e,r],scimgroupcreate:([e,r,s])=>(s??(s=new l(this.config).groupBase(r)),[this.groupPattern.test(o(s).join(","))?{...e,id:t()}:e,r,s])},this.groupBase=this.config.twake_group_base||"",this.userBase=this.config.twake_group_user_base||"";for(const[t,e]of[["--twake-group-base",this.groupBase],["--twake-group-user-base",this.userBase]])if(2!==e.split($).length)throw new Error(`${this.name}: ${t} must hold ${$} once`);this.groupPattern=branchPattern(this.groupBase),this.attrs=v(this.config),this.orgPattern=this.config.twake_group_organization_dn||"",this.displayName=this.config.twake_group_display_name_attribute||"twakeDisplayName",this.color=this.config.twake_group_color_attribute||"twakeGroupColor",this.createdAt=this.config.twake_group_created_at_attribute||"twakeCreatedAt",this.maxPage=this.config.twake_group_max_page_limit||1e3,this.memberFields={...j,...this.config.twake_group_member_fields}}adaptSchema(t){const e={[this.displayName]:{type:"string",required:!0},[this.color]:{type:"string",test:z.source},[this.createdAt]:{type:"string"}},r=this.config.ldap_organization_link_attribute;r&&this.orgPattern&&(e[r]={type:"string",generated:!0});for(const[r,s]of Object.entries(e)){const e=Object.keys(t.attributes).find(t=>t.toLowerCase()===r.toLowerCase()),i=e?t.attributes[e]:s;e&&delete t.attributes[e],t.attributes[r]=i}return t}afterLoad(){if(!this.server.loadedPlugins.scim)return;const t=this.config.scim_group_rdn_attribute||"cn",e=this.config.scim_group_mapping||"",r=f(y,e?w(e):void 0).entries.find(e=>e.ldap?.toLowerCase()===t.toLowerCase());r&&this.logger.warn(`${this.name}: the SCIM group mapping writes ${r.scim} to ${t}, so SCIM groups keep it as their RDN instead of a generated cn: map ${r.scim} to another attribute with --scim-group-mapping`)}linked(t,r,s){const i=this.config.ldap_organization_link_attribute;if(!i||!this.orgPattern||!this.judgedByAttachment())return s;const o=withOrg(this.orgPattern,r),a=read(s,i);if(void 0!==a){if(dnKey(a)!==dnKey(o))throw new e(`${t} lies in the branch of ${r}, and must be linked to it`);return s}const n=values(s.objectClass);return n.some(t=>A.includes(t.toLowerCase()))||n.push("twakeGroup"),{...s,objectClass:n,[i]:o}}checkLinkChange(t,r){const s=this.config.ldap_organization_link_attribute,i=this.organizationOf(t);if(void 0===i||!s||!this.orgPattern||!this.judgedByAttachment())return;const o=dnKey(withOrg(this.orgPattern,i)),named=t=>Object.keys(t??{}).filter(t=>t.toLowerCase()===s.toLowerCase()),a=[...named(r.add).flatMap(t=>values(r.add[t])),...named(r.replace).flatMap(t=>values(r.replace[t]))],n=Array.isArray(r.delete)?r.delete.some(t=>t.toLowerCase()===s.toLowerCase()):named(r.delete).length>0,c=named(r.replace).length>0&&0===a.length;if(n||c||a.some(t=>dnKey(t)!==o))throw new e(`${t} lies in the branch of ${i}, and must stay linked to it`)}judgedByAttachment(){return Object.values(this.server.loadedPlugins).some(t=>t.roles?.includes("authz")&&!0===t.judgesByAttachment?.())}organizationOf(t){return organizationIn(this.groupPattern,t)}groupBaseOf(t){return withOrg(this.groupBase,t)}userBaseOf(t){return withOrg(this.userBase,t)}checkMembers(t,r){const s=this.organizationOf(t);if(!s)return;const i=this.foreign(s,r);if(void 0!==i)throw new e(`${i} is not a user of organization ${s}`)}foreign(t,e){const r=dnKey(this.userBaseOf(t));return values(e).find(t=>!a(t,this.config.group_dummy_user)&&dnKey(parentOf(t))!==r)}async hideTombstones(t){if(!this.attrs.deleted)return t;const e=new Set;for(const r of t.searchEntries){const t=this.organizationOf(r.dn);t&&void 0!==r.member&&e.add(t)}if(0===e.size)return t;const r=new Set;for(const t of e)for(const e of await this.tombstonesOf(t))r.add(dnKey(e));return 0===r.size?t:{...t,searchEntries:t.searchEntries.map(t=>void 0!==t.member&&this.organizationOf(t.dn)?{...t,member:values(t.member).filter(t=>!r.has(dnKey(t)))}:t)}}async tombstonesOf(t){const{deleted:e,deletedValue:r}=this.attrs;if(!e)return[];try{const{searchEntries:s}=await this.ldap.system.search({paged:!1,scope:"one",filter:`(${e}=${n(r)})`,attributes:["dn"]},this.userBaseOf(t));return s.map(t=>t.dn)}catch(t){if(32===i(t))return[];throw t}}api(t){const e=`${this.config.api_prefix}/v1/organizations/:id/groups`,r=this.organizationRoute.bind(this);t.get(e,r("listing groups",(t,e,r)=>this.listRoute(t,e,r))),t.post(e,r("creating group",(t,e,r)=>this.createRoute(t,e,r))),t.get(`${e}/:groupId`,r("getting group",async(t,e,r)=>{const s=await this.readGroup(r,t.params.groupId,t);if(!s)throw notFound("group");e.json(s)})),t.get(`${e}/:groupId/members`,r("listing group members",(t,e,r)=>this.membersRoute(t,e,r))),t.patch(`${e}/:groupId`,r("updating group",(t,e,r)=>this.updateRoute(t,e,r))),t.delete(`${e}/:groupId`,r("deleting group",async(t,e,r)=>{await this.onGroup(()=>this.deleteGroup(this.groupDn(r,t.params.groupId),t)),e.json({success:!0})})),t.post(`${e}/:groupId/members`,r("adding group members",(t,e,r)=>this.addMembersRoute(t,e,r))),t.delete(`${e}/:groupId/members/:userId`,r("removing group member",(t,e,r)=>this.removeMemberRoute(t,e,r)))}organizationRoute(t,e,i=this.name){return async(o,a)=>{try{const t=o.params.id;await this.checkOrganization(t,o),await e(o,a,t)}catch(e){if(e instanceof RouteError)return void a.status(e.status).json({error:e.message,code:e.code});if(c(e))return void a.status(403).json({error:r,code:"REFUSED"});if(e instanceof s&&e.statusCode<500)return void a.status(e.statusCode).json({error:e.message,code:400===e.statusCode?"INVALID_INPUT":"REFUSED"});this.logger.error({plugin:i,context:t,error:String(e)}),a.status(500).json({error:`Error ${t}`,code:"INTERNAL_ERROR"})}}}groupDn(t,e){return`${this.cn}=${u(e)},${this.groupBaseOf(t)}`}get userAttribute(){return this.config.ldap_user_main_attribute||"uid"}userDn(t,e){return`${this.userAttribute}=${u(e)},${this.userBaseOf(t)}`}async checkOrganization(t,e){if(!this.orgPattern)return;const r=this.config.twake_group_organization_status_attribute||"twakeOrgStatus",s=withOrg(this.orgPattern,t);if(!await this.ldap.readableBy(s,e))throw notFound("organization");let o;try{o=(await this.ldap.system.search({paged:!1,scope:"base",attributes:[r]},s)).searchEntries[0]}catch(t){if(32!==i(t))throw t}if(!o)throw notFound("organization");const a=this.config.twake_group_organization_deleted_value||"deleted";if(read(o,r)===a)throw new RouteError(410,"Organization has been deleted","ORGANIZATION_DELETED")}page(t,e){const r=Math.max(1,parseInt(t.query.page)||1),s=Math.max(1,Math.min(parseInt(t.query.limit)||20,this.maxPage)),i=t.query.search||void 0;if(void 0!==i&&("string"!=typeof i||i.length<2))throw invalid("Search query must be at least 2 characters","INVALID_SEARCH_QUERY");const o=t.query.sortBy;if(void 0!==o&&!e.includes(o))throw invalid(`sortBy must be one of: ${e.join(", ")}`,"INVALID_SORT_FIELD");return{page:r,limit:s,offset:(r-1)*s,search:i,sortBy:o,desc:"desc"===t.query.sortOrder}}sorted(t,e,r){if(!e)return t;const s=r?-1:1;return t.sort((t,r)=>text(t[e]).localeCompare(text(r[e]))*s)}groupOf(t,e){const r=read(e,this.cn)||"",s=dnKey(this.userBaseOf(t)),i=values(e.member).filter(t=>!a(t,this.config.group_dummy_user)).map(t=>dnKey(parentOf(t))===s?h(t):t);return{id:r,cn:r,displayName:read(e,this.displayName)??r,description:read(e,"description"),color:read(e,this.color),organizationId:t,baseDN:this.groupDn(t,r),members:i,createdAt:read(e,this.createdAt)}}async groups(t,e,r,s){try{const{searchEntries:i}=await this.ldap.search({paged:!1,scope:"one",filter:e,...r&&{attributes:r}},this.groupBaseOf(t),s);return i}catch(t){if(32===i(t))return[];throw t}}async readGroup(t,e,r){try{const{searchEntries:s}=await this.ldap.search({paged:!1,scope:"base"},this.groupDn(t,e),r);return s[0]&&this.groupOf(t,s[0])}catch(t){if(32===i(t)||c(t))return;throw t}}async named(t,e){const[r]=await this.groups(t,`(${this.displayName}=${n(e)})`);return r&&read(r,this.cn)}async onGroup(t){try{return await t()}catch(t){if(32===i(t))throw notFound("group");throw t}}async listRoute(t,e,r){const s=this.page(t,k);let i=`(${this.cn}=*)`;if(s.search){const t=n(s.search);i=`(&${i}(|(${this.displayName}=*${t}*)(description=*${t}*)(&(!(${this.displayName}=*))(${this.cn}=*${t}*))))`}const o=this.sorted((await this.groups(r,i,void 0,t)).map(t=>this.groupOf(r,t)),s.sortBy,s.desc);e.json({organizationId:r,groups:o.slice(s.offset,s.offset+s.limit),pagination:{page:s.page,limit:s.limit,total:o.length,totalPages:Math.ceil(o.length/s.limit)}})}async createRoute(e,r,s){const i=bodyOf(e);if(!isDisplayName(i.name))throw invalid(P);if(void 0!==i.description&&"string"!=typeof i.description)throw invalid("description must be a string");if(void 0!==i.color&&!isColor(i.color))throw invalid("color must be a hex code (e.g. #RRGGBB or #RGB)");const o=t(),a={objectClass:this.config.group_class||["top","groupOfNames"],[this.displayName]:i.name,[this.createdAt]:(new Date).toISOString()};i.description&&(a.description=i.description),i.color&&(a[this.color]=i.color);const n=this.linked(this.groupDn(s,o),s,a);await this.ldap.authorize("ldapaddrequest",[this.groupDn(s,o),this.candidateGroup(o,[],n),e]);const c=i.name;if(await b(()=>this.named(s,c)))throw new RouteError(409,"Group already exists","GROUP_EXISTS");await this.addGroup(this.groupDn(s,o),[],n,e);const u=await b(()=>this.readGroup(s,o));r.status(201).json(u)}async updateRoute(t,e,r){const s=bodyOf(t),i=t.params.groupId,o=Object.keys(s).filter(t=>!["name","description","color"].includes(t));if(o.length)throw invalid(`Only "name", "description" and "color" can be updated; unsupported field(s): ${o.join(", ")}`);const{name:a,description:n,color:c}=s;if(void 0===a&&void 0===n&&void 0===c)throw invalid('at least one of "name", "description" or "color" must be provided');if(void 0!==a&&!isDisplayName(a))throw invalid(P);if(void 0!==n&&"string"!=typeof n)throw invalid("description must be a string");if(void 0!==c&&""!==c&&!isColor(c))throw invalid("color must be a hex code (e.g. #RRGGBB or #RGB) or empty to clear it");const u={};if(void 0!==a&&(u[this.displayName]=a),void 0!==n&&(u.description=n||[]),void 0!==c&&(u[this.color]=c||[]),await this.ldap.authorize("ldapmodifyrequest",[this.groupDn(r,i),{replace:u},this.opNumber(),t]),void 0!==a){const t=await b(()=>this.named(r,a));if(t&&t!==i)throw new RouteError(409,"Group already exists","GROUP_EXISTS")}await this.onGroup(()=>this.modifyGroup(this.groupDn(r,i),{replace:u},t)),e.json({success:!0})}async membersRoute(t,e,r){const s=this.page(t,I),i=t.params.groupId,o=await this.readGroup(r,i,t);if(!o)throw notFound("group");e.json({organizationId:r,id:i,...await this.memberPage(s,r,o.members,void 0,t)})}async memberPage(t,e,r,s=()=>({}),i){const o=[...new Map(r.map(t=>[t.toLowerCase(),t])).values()],a=await this.profiles(e,o,i);let n=o.map(t=>({...a.get(t.toLowerCase())??{uid:t},...s(t)}));if(t.search){const e=t.search.toLowerCase();n=n.filter(t=>[t.uid,t.displayName||t.cn,t.mail].some(t=>text(t).toLowerCase().includes(e)))}n=this.sorted(n,t.sortBy??"uid",t.desc);const c=Math.ceil(n.length/t.limit);return{members:n.slice(t.offset,t.offset+t.limit),pagination:{page:t.page,limit:t.limit,total:n.length,totalPages:c,hasNextPage:t.page<c,hasPreviousPage:t.page>1}}}async users(t,e,r,s){const{deleted:o,deletedValue:a}=this.attrs,u=o?`(!(${o}=${n(a)}))`:"",h=[];for(let o=0;o<e.length;o+=this.maxPage){const a=`(&(|${e.slice(o,o+this.maxPage).map(t=>`(${this.userAttribute}=${n(t)})`).join("")})${u})`;try{const{searchEntries:e}=await this.ldap.search({paged:!1,scope:"one",filter:a,attributes:r},this.userBaseOf(t),s);h.push(...e)}catch(t){if(32!==i(t)&&!c(t))throw t}}return h}async profiles(t,e,r){const s=[...new Set([...Object.values(this.memberFields),...Object.values(N),R])],i=new Map;for(const o of await this.users(t,e,s,r)){const t=read(o,this.userAttribute);if(!t)continue;const e={};for(const[t,r]of Object.entries(this.memberFields)){const s=read(o,r);void 0!==s&&(e[t]=s)}const r={};for(const[t,e]of Object.entries(N)){const s=read(o,e);void 0!==s&&(r[t]=s)}Object.keys(r).length&&(e.name=r),e.isTechnical="TRUE"===read(o,R)?.toUpperCase(),i.set(t.toLowerCase(),e)}return i}async addMembersRoute(t,e,r){const{usernames:s}=bodyOf(t);if(!Array.isArray(s)||0===s.length)throw invalid("usernames must be a non-empty array");if(!s.every(t=>"string"==typeof t&&t.length>0))throw invalid("usernames must be an array of non-empty strings");if(s.length>this.maxPage)throw invalid(`usernames cannot exceed ${this.maxPage} entries per request`);const i=t.params.groupId,o=[...new Map(s.map(t=>[t.toLowerCase(),t])).keys()];await this.ldap.authorize("ldapmodifyrequest",[this.groupDn(r,i),{add:{member:o.map(t=>this.userDn(r,t))}},this.opNumber(),t]);const a=await this.readGroup(r,i,t);if(!a)throw notFound("group");const n=new Map((await this.users(r,o,[this.userAttribute],t)).map(t=>{const e=read(t,this.userAttribute)||"";return[e.toLowerCase(),e]}));if(o.some(t=>!n.has(t)))throw notFound("user");const c=new Set(a.members.map(t=>t.toLowerCase())),u=o.filter(t=>!c.has(t)).map(t=>this.userDn(r,n.get(t))),h=this.groupDn(r,i);u.length&&(await this.onGroup(()=>this.ldap.modify(h,{add:{member:u}},t)),await this.dropPlaceholder(h,t)),e.json({success:!0})}async dropPlaceholder(t,e){const r=this.config.group_dummy_user;if(r)try{await this.ldap.modify(t,{delete:{member:r}},e)}catch(t){if(![16,65].includes(i(t)??0))throw t}}async removeMemberRoute(t,e,r){const s=this.groupDn(r,t.params.groupId),o=this.userDn(r,t.params.userId);try{await this.ldap.modify(s,{delete:{member:o}},t)}catch(e){const r=i(e);if(32===r)throw notFound("group");if(16===r)throw notFound("member");if(65!==r||!this.config.group_dummy_user)throw e;await this.ldap.modify(s,{replace:{member:[this.config.group_dummy_user]}},t)}e.json({success:!0})}}export{P as NAME_RULE,$ as ORG,RouteError,bodyOf,branchPattern,TwakeGroups as default,dnKey,invalid,isDisplayName,movedInto,notFound,organizationIn,parentOf,read,values,withOrg};
@@ -1 +1 @@
1
- import{randomUUID as e}from"node:crypto";import t from"../../abstract/plugin.js";import{BadRequestError as s}from"../../lib/errors.js";import{extractLdapCode as a}from"../../lib/ldapCodes.js";import{currentOperation as i}from"../../lib/operation.js";import{parseDn as r,unescapeDnValue as n,escapeDnValue as o,escapeLdapFilter as h}from"../../lib/utils.js";import{organizationIn as c,dnKey as d,movedInto as l,ORG as p,branchPattern as u,values as g,parentOf as m,read as f,rdnValue as w,notFound as b,RouteError as y,invalid as v,bodyOf as k,isDisplayName as O,NAME_RULE as $}from"./groups.js";import{isTombstone as _,valueOf as I,lifecycleAttributes as S}from"./lifecycleAttributes.js";const C=["viewer","editor","admin"],isRole=e=>C.includes(e),M=[...C].reverse(),L="twake.space.sync.requested",q=["name"],j=["uid","displayName","mail","jobTitle","role"],A=new RegExp(`:(${C.join("|")})$`,"i"),conflict=(e,t)=>new y(409,e,t),lastAdmin=()=>conflict("A space keeps at least one admin","LAST_ADMIN"),isAdmin=e=>"member"===e.kind&&"admin"===e.role;function roleOf(e){if(!isRole(e.role))throw v(`role must be one of: ${C.join(", ")}`);return e.role}function checkLength(e,t,s){if(e.length>s)throw v(`${t} cannot exceed ${s} entries per request`)}function namesOf(e,t,s){if(!Array.isArray(e)||0===e.length||!e.every(e=>"string"==typeof e&&e.length>0))throw v(`${t} must be a non-empty array of non-empty strings`);return checkLength(e,t,s),[...new Map(e.map(e=>[e.toLowerCase(),e])).values()]}function holdersOf(e,t,s){const a="id"===t?"groups":"members";if(!Array.isArray(e))throw v(`${a} must be an array of { ${t}, role }`);checkLength(e,a,s);const i=new Set;return e.map(e=>{const{[t]:s,role:r}=e??{};if("string"!=typeof s||0===s.length||!isRole(r))throw v(`${a} must be an array of { ${t}, role }, role one of: ${C.join(", ")}`);if(i.has(s.toLowerCase()))throw v(`${s} is listed twice in ${a}`);return i.add(s.toLowerCase()),{name:s,role:r}})}class TwakeSpaces extends t{constructor(e){if(super(e),this.name="twakeSpaces",this.roles=["api"],this.dependencies={twakeGroups:"core/twake/groups",onLdapChange:"core/ldap/onChange"},this.domains=new Map,this.following=Promise.resolve(),this.publishing=Promise.resolve(),this.unlinking=new Map,this.announced=new Map,this.handing=new Map,this.hooks={ldapaddrequest:([e,t,s])=>{for(const s of Object.values(this.roleAttributes))this.checkHolders(e,I(t,s));return[e,t,s]},ldapmodifyrequest:([e,t,s,a])=>{for(const s of Object.values(this.roleAttributes))for(const a of[t.add,t.replace])a&&this.checkHolders(e,I(a,s));return[e,t,s,a]},ldaprenamerequest:async([e,t,a])=>{const i=l(this.spacePattern,e,t);if(void 0===i)return[e,t,a];const r=Object.values(this.roleAttributes),n=await this.entry(e,r);if(n&&r.some(e=>void 0!==this.foreign(i,I(n,e))))throw new s(`The space holds users or groups of another organization than ${i}`);return[e,t,a]},ldapdeleterequest:async([e,t])=>{const s=i();if(void 0!==s)for(const t of[e].flat()){const e=c(this.userPattern,t);if(void 0!==e){if(this.tombstone?.keeps(t))continue;const a=await this.administered(e,t);if(!a.length)continue;this.handing.has(s)||this.handing.set(s,new Map),this.handing.get(s).set(d(t),{org:e,ids:a})}else if(this.rabbitmq){const e=await this.deletedGroup(t);e&&this.unlinking.set(d(t),{...e,op:s})}}return[e,t]},ldapdeletedone:(e,t={})=>{const s=i(),a=void 0===s?void 0:this.handing.get(s);for(const s of[e].flat()){const e=this.unlinking.get(d(s));e&&delete e.op;const i=a?.get(d(s));i&&this.queue(s,()=>this.handOver(i.org,s,i.ids,t))}},ldapdeleteend:e=>{this.handing.delete(e);for(const[t,s]of this.unlinking)if(s.op===e){this.unlinking.delete(t);for(const{id:e,context:t,fields:a}of s.owed??[])this.publishEvent(s.org,e,"group.unlinked",t,a)}},onLdapEntryChange:(e,t,s,a)=>{const i=this.orgPattern?.exec(r(e).join(","))?.groups;i&&this.rabbitmq&&(this.publishing=this.publishing.then(()=>{this.domains.delete(n(i.org).toLowerCase())})),this.follows&&this.queue(e,()=>this.follow(e,t,s,a));const o=c(this.userPattern,e);void 0!==o&&this.lifecycle.deleted&&t&&s&&!_(t,this.lifecycle)&&_(s,this.lifecycle)&&this.queue(e,async()=>this.handOver(o,e,await this.administered(o,e),a))}},this.spaceBase=this.config.twake_space_base||"",!this.spaceBase.includes(p))throw new Error(`${this.name}: --twake-space-base must hold ${p}`);this.spacePattern=u(this.spaceBase),this.userPattern=u(this.config.twake_group_user_base),this.lifecycle=S(this.config),this.displayName=this.config.twake_space_display_name_attribute||"twakeDisplayName",this.roleAttributes={admin:this.config.twake_space_admin_attribute||"twakeSpaceAdmin",editor:this.config.twake_space_editor_attribute||"twakeSpaceEditor",viewer:this.config.twake_space_viewer_attribute||"twakeSpaceViewer"},this.userRole=this.config.twake_space_user_role_attribute||"",this.exchange=this.config.twake_space_exchange||"space",this.domainAttribute=this.config.twake_instance_organization_domain_attribute||"twakeDomain",this.config.twake_group_organization_dn&&(this.orgPattern=u(this.config.twake_group_organization_dn)),this.config.rabbitmq_url&&(this.dependencies.rabbitmq="core/rabbitmq")}get follows(){return Boolean(this.userRole||this.config.rabbitmq_url)}get groups(){return this.server.loadedPlugins.twakeGroups}get tombstone(){return this.server.loadedPlugins.twakeTombstone}async assertComposition(){if(this.config.rabbitmq_url&&!await(this.rabbitmq?.getRawClient()))throw new Error(`${this.name}: RabbitMQ at --rabbitmq-url cannot be reached`);if(this.config.rabbitmq_url&&this.orgPattern&&!(await this.server.ldap.schemaIndex()).getAttributeType(this.domainAttribute))throw new Error(`${this.name}: the directory schema defines no ${this.domainAttribute}; load a schema that does, or set --twake-instance-organization-domain-attribute`);if(!this.follows||!this.server.loadedPlugins.trash)return;const type=e=>e.split("=")[0].toLowerCase(),e=[this.config.twake_group_base||"",this.spaceBase].map(e=>{const t=r(e).reverse();return e=>r(e).reverse().slice(0,t.length).every((e,s)=>t[s].includes(p)?type(e)===type(t[s]):d(e)===d(t[s]))}),t=String(this.config.trash_watched_bases||"").split(";").map(e=>e.trim()).filter(Boolean);if(0===t.length||t.some(t=>e.some(e=>e(t))))throw new Error(`${this.name}: core/ldap/trash watches the organization groups or spaces, and the deletion of one would not be followed: no user role value or event would say it went. Leave the group and space branches out of --trash-watched-bases`)}queue(e,t){this.following=this.following.then(()=>t().catch(t=>{this.logger.error({plugin:this.name,event:"follow",dn:e,error:String(t)})}))}organizationOf(e){return c(this.spacePattern,e)}spaceBaseOf(e){return this.spaceBase.replace(p,o(e))}spaceDn(e,t){return`cn=${o(t)},${this.spaceBaseOf(e)}`}async entry(e,t){try{return(await this.server.ldap.system.search({paged:!1,scope:"base",attributes:t},e)).searchEntries[0]}catch(e){if(32===a(e))return;throw e}}checkHolders(e,t){const a=this.organizationOf(e);if(!a)return;const i=this.foreign(a,t);if(void 0!==i)throw new s(`${i} is neither a user nor a group of organization ${a}`)}foreign(e,t){const s=[this.groups.userBaseOf(e),this.groups.groupBaseOf(e)].map(d);return g(t).find(e=>!s.includes(d(m(e))))}async api(e){const t=`${this.config.api_prefix}/v1/organizations/:id/spaces`,route=(e,t)=>this.groups.organizationRoute(e,t,this.name),done=e=>{e.json({success:!0})},spaceId=e=>e.params.spaceId;e.get(t,route("listing spaces",(e,t,s)=>this.listRoute(e,t,s))),e.post(t,route("creating space",(e,t,s)=>this.createRoute(e,t,s))),e.get(`${t}/:spaceId`,route("getting space",async(e,t,s)=>{t.json(this.spaceOf(s,await this.space(s,spaceId(e),e)))})),e.patch(`${t}/:spaceId`,route("renaming space",async(e,t,s)=>{const a=k(e),i=Object.keys(a).filter(e=>"name"!==e);if(i.length)throw v(`Only "name" can be updated; unsupported field(s): ${i.join(", ")}`);if(!O(a.name))throw v($);await this.write(s,spaceId(e),{replace:{[this.displayName]:a.name}},e),done(t)})),e.delete(`${t}/:spaceId`,route("deleting space",async(e,t,s)=>{try{await this.server.ldap.delete(this.spaceDn(s,spaceId(e)),e)}catch(e){if(32===a(e))throw b("space");throw e}done(t)})),e.get(`${t}/:spaceId/members`,route("listing space members",async(e,t,s)=>{const a=this.groups.page(e,j),i=await this.space(s,spaceId(e),e),r=i.holders.filter(e=>"member"===e.kind),n=new Map(r.map(e=>[e.name.toLowerCase(),e.role]));t.json({organizationId:s,id:i.id,...await this.groups.memberPage(a,s,r.map(e=>e.name),e=>({role:n.get(e.toLowerCase())}),e)})})),e.post(`${t}/:spaceId/members`,route("adding space members",async(e,t,s)=>{const a=k(e),i=namesOf(a.usernames,"usernames",this.groups.maxPage);await this.add(e,s,"member",roleOf(a),()=>this.liveUsers(s,i,e)),done(t)})),e.patch(`${t}/:spaceId/members/:userId`,route("changing space member role",async(e,t,s)=>{await this.change(e,s,"member",roleOf(k(e))),done(t)})),e.delete(`${t}/:spaceId/members/:userId`,route("removing space member",async(e,t,s)=>{await this.change(e,s,"member"),done(t)})),e.get(`${t}/:spaceId/groups`,route("listing space groups",async(e,t,s)=>{const a=await this.space(s,spaceId(e),e),i=a.holders.filter(e=>"group"===e.kind),r=new Map((await this.orgGroups(s,i.map(e=>e.name),[],e)).map(e=>[d(e.dn),this.groups.groupOf(s,e).displayName])),n=i.map(({name:e,role:t,dn:s})=>({id:e,name:r.get(d(s))??e,role:t})).sort((e,t)=>e.name.localeCompare(t.name));t.json({organizationId:s,id:a.id,groups:n})})),e.post(`${t}/:spaceId/groups`,route("linking space groups",async(e,t,s)=>{const a=k(e),i=namesOf(a.groupIds,"groupIds",this.groups.maxPage);await this.add(e,s,"group",roleOf(a),()=>this.groupDns(s,i,e)),done(t)})),e.patch(`${t}/:spaceId/groups/:groupId`,route("changing space group role",async(e,t,s)=>{await this.change(e,s,"group",roleOf(k(e))),done(t)})),e.delete(`${t}/:spaceId/groups/:groupId`,route("unlinking space group",async(e,t,s)=>{await this.change(e,s,"group"),done(t)})),await(this.rabbitmq?.subscribe(this.exchange,L,this.config.twake_space_sync_queue||`${L}.ldap-rest`,e=>this.onSyncRequested(e)))}spaceOf(e,t){return{id:t.id,name:t.name,organizationId:e,members:t.holders.filter(e=>"member"===e.kind).map(({name:e,role:t})=>({username:e,role:t})),groups:t.holders.filter(e=>"group"===e.kind).map(({name:e,role:t})=>({id:e,role:t}))}}entryOf(e,t,s){const a=new Map([[d(this.groups.userBaseOf(e)),"member"],[d(this.groups.groupBaseOf(e)),"group"]]),i=f(t,"cn")||"",r=new Map;for(const e of M)for(const i of g(I(t,this.roleAttributes[e]))){const t=d(i),n=a.get(d(m(i)));if(!n||s.has(t))continue;const o=r.get(t);o?o.roles.push(e):r.set(t,{kind:n,name:w(i),role:e,roles:[e],dn:i})}return{id:i,name:f(t,this.displayName)??i,holders:[...r.values()]}}async spaces(e,t,s,i,r){const[n,o]=await Promise.all([(async()=>{try{return(await this.server.ldap.search({paged:!1,scope:void 0===s?"one":"base",filter:t,attributes:["cn",this.displayName,...Object.values(this.roleAttributes)]},void 0===s?this.spaceBaseOf(e):this.spaceDn(e,s),i)).searchEntries}catch(e){if(32===a(e))return[];throw e}})(),r??this.hidden(e)]);return n.map(t=>this.entryOf(e,t,o))}async hidden(e){return new Set((await this.groups.tombstonesOf(e)).map(d))}holding(e){return`(|${e.flatMap(e=>Object.values(this.roleAttributes).map(t=>`(${t}=${h(e)})`)).join("")})`}async space(e,t,s){const[a]=await this.spaces(e,"(objectClass=*)",t,s);if(!a)throw b("space");return a}async write(e,t,s,i){try{await this.server.ldap.modify(this.spaceDn(e,t),s,i)}catch(e){if(32===a(e))throw b("space");throw e}}async again(e){for(let t=0;;t++)try{return await e()}catch(e){const s=a(e);if(16!==s&&20!==s||t>=2)throw e}}async liveUsers(e,t,s){const a=this.groups.userAttribute,i=new Map((await this.groups.users(e,t,[a],s)).map(e=>[(f(e,a)||"").toLowerCase(),e.dn]));if(t.some(e=>!i.has(e.toLowerCase())))throw b("user");return i}get groupName(){return this.config.twake_group_display_name_attribute||"twakeDisplayName"}async orgGroups(e,t,s=[],a){const i=this.groups.cn,r=[];for(let n=0;n<t.length;n+=this.groups.maxPage){const o=t.slice(n,n+this.groups.maxPage);r.push(...await this.groups.groups(e,`(|${o.map(e=>`(${i}=${h(e)})`).join("")})`,[i,this.groupName,...s],a))}return r}async follow(e,t,s,a){let i=this.organizationOf(e);if(void 0!==i){const[e,r]=[t,s].map(e=>e?this.entryOf(i,e,new Set):void 0),n=[...new Set([e,r].flatMap(e=>e?[e.id]:[]))],o=[e,r].map(e=>new Map(e?.holders.map(e=>[d(e.dn),e.role]))),h=[e,r].flatMap(e=>e?.holders??[]).filter(e=>n.length>1||o[0].get(d(e.dn))!==o[1].get(d(e.dn))),c=new Set(h.filter(e=>"group"===e.kind).map(e=>e.name)),l=await this.orgGroups(i,[...c],["member"]),p=(r??e).id,u=await this.settle(i,[...h.filter(e=>"member"===e.kind).map(e=>e.dn),...l.flatMap(e=>g(e.member))],{id:p,space:e},await this.hidden(i));return void(this.rabbitmq&&await this.announce(i,u,a,n,{was:e,is:r,linked:l}))}if(i=this.groups.organizationOf(e),void 0===i||!t)return;const r=g(t.member),n=g(s?.member),[o,h]=[r,n].map(e=>new Set(e.map(d))),c=s?void 0:this.unlinking.get(d(e));c&&this.unlinking.delete(d(e));const l=await this.hidden(i),p=await this.settle(i,[...r.filter(e=>!h.has(d(e))),...n.filter(e=>!o.has(d(e)))],{group:e,members:o,spaces:c?.spaces},l);if(!this.rabbitmq||!c&&!p.length)return;const u=(await this.spaces(i,this.holding([e]),void 0,void 0,l)).map(e=>e.id);c&&this.unlinked(e,c,u,a),await this.announce(i,p,a,[...c?.spaces.map(e=>e.id)??[],...u])}async administered(e,t){try{const s=`(${this.roleAttributes.admin}=${h(t)})`;return(await this.spaces(e,s)).map(e=>e.id)}catch(e){return this.logger.error({plugin:this.name,event:"handOver",dn:t,error:String(e)}),[]}}async handOver(e,t,s,a){for(const i of s)try{await this.again(()=>this.handOverOnce(e,t,i,a))}catch(e){this.logger.error({plugin:this.name,event:"handOver",space:i,error:String(e)})}}async handOverOnce(e,t,s,a){const i=d(t),[r]=await this.spaces(e,"(objectClass=*)",s),n=r?.holders.filter(e=>"member"===e.kind&&d(e.dn)!==i)??[];if(!r||n.some(e=>"admin"===e.role))return;if(!n.length)return void await this.server.ldap.system.delete(this.spaceDn(e,s),void 0,{context:a});const o=n.some(e=>"editor"===e.role)?"editor":"viewer",h=n.filter(e=>e.role===o).map(e=>e.dn);await this.server.ldap.system.modify(this.spaceDn(e,s),{delete:{[this.roleAttributes[o]]:h},add:{[this.roleAttributes.admin]:h}},void 0,{context:a})}async deletedGroup(e){const t=this.groups.organizationOf(e);if(void 0!==t)try{const s=(await this.spaces(t,this.holding([e]))).flatMap(t=>{const s=t.holders.find(t=>d(t.dn)===d(e));return s?[{id:t.id,role:s.role}]:[]});if(!s.length)return;const[a]=await this.orgGroups(t,[w(e)]),i=w(e);return{org:t,id:i,name:a&&f(a,this.groupName)||i,spaces:s}}catch(t){return void this.logger.error({plugin:this.name,event:"deleting",dn:e,error:String(t)})}}unlinked(e,t,s,a){const{org:i,id:r,name:n,spaces:o}=t,h=new Set(s.map(e=>e.toLowerCase()));for(const{id:t,role:s}of o){const o=d(this.spaceDn(i,t));h.has(t.toLowerCase())&&(this.announced.has(o)||this.announced.set(o,new Set),this.announced.get(o).add(d(e))),this.publishEvent(i,t,"group.unlinked",a,{groups:[{id:r,name:n,role:s}]})}}async settle(e,t,s,a){const i=d(this.groups.userBaseOf(e)),r=new Map(t.filter(e=>d(m(e))===i).map(e=>[d(e),e])),n=[];for(const[t,i]of r)if(!a.has(t))try{const t=await this.rolesOf(e,i,a),r=this.userRole?await this.writeRoles(i,t):await this.rolesOf(e,i,a,s);r&&n.push({dn:i,before:r,after:t})}catch(e){this.logger.error({plugin:this.name,event:"settle",dn:i,error:String(e)})}return n}async userIn(e,t,s,a){const i=(await this.groups.groups(e,`(member=${h(t)})`,[this.groups.cn],s)).map(e=>d(e.dn)),r=new Set([d(t),...i]),n=[t,...i];if(a&&"group"in a){const e=d(a.group);r.delete(e),a.members.has(d(t))&&r.add(e),n.push(a.group)}return{filter:this.holding(n),roleIn:e=>e.holders.find(e=>r.has(d(e.dn)))?.role}}async announce(e,t,s,a,i){const r=new Set;if(i){const{was:a,is:n,linked:o}=i,h=(n??a).id,c=new Map(o.map(e=>[d(e.dn),f(e,this.groupName)||w(e.dn)])),groupOf=e=>({id:e.name,name:c.get(d(e.dn))??e.name,role:e.role}),publish=(t,a)=>this.publishEvent(e,h,t,s,a),l=d(this.spaceDn(e,h)),p=new Set;for(const t of[a,n]){const s=t&&d(this.spaceDn(e,t.id));for(const e of s&&this.announced.get(s)||[])p.add(e);s&&this.announced.delete(s)}if(a&&n){a.name!==n.name&&publish("updated",{name:n.name});const e=new Map(a.holders.filter(e=>"group"===e.kind).map(e=>[d(e.dn),e]));for(const t of n.holders.filter(e=>"group"===e.kind)){const s=e.get(d(t.dn));e.delete(d(t.dn)),s?.role!==t.role&&publish(s?"group.role.changed":"group.linked",{groups:[groupOf(t)]})}const byDeletion=e=>{if(p.delete(d(e.dn)))return!0;const t=this.unlinking.get(d(e.dn));return!!t?.spaces.some(e=>e.id.toLowerCase()===h.toLowerCase())&&(void 0!==t.op&&(t.owed??(t.owed=[])).push({id:h,context:s,fields:{groups:[groupOf(e)]}}),!0)};for(const t of e.values())byDeletion(t)||publish("group.unlinked",{groups:[groupOf(t)]});const[t,i]=[a,n].map(e=>new Set(e.holders.map(e=>d(e.dn)))),r=[...p].filter(e=>t.has(e)&&i.has(e));r.length&&this.announced.set(l,new Set(r))}else if(r.add(h.toLowerCase()),n){const s=t.flatMap(e=>{const t=e.after.get(h);return t?[{dn:e.dn,role:t}]:[]}),a=await this.profiles(e,s.map(e=>e.dn));publish("created",{name:n.name,members:s.flatMap(({dn:e,role:t})=>{const s=a.get(d(e));return s?[{...s,role:t}]:[]}),groups:n.holders.filter(e=>"group"===e.kind).map(groupOf)})}else publish("deleted")}const n=new Set(a.map(e=>e.toLowerCase())),o=t.flatMap(({dn:e,before:t,after:s})=>[...new Set([...t.keys(),...s.keys()])].flatMap(a=>{const[i,o]=[t.get(a),s.get(a)];if(i===o||r.has(a.toLowerCase()))return[];const h=!n.has(a.toLowerCase());if(h&&!this.userRole)return[];return[{dn:e,id:a,event:i?o?"member.role.changed":"member.removed":"member.added",role:o??i,caught:h}]}));if(!o.length)return;const h=await this.profiles(e,o.map(e=>e.dn));for(const{dn:t,id:a,event:i,role:r,caught:n}of o){const o=h.get(d(t));o&&this.publishEvent(e,a,i,n?{}:s,{members:[{...o,role:r}]})}}async profiles(e,t){if(!t.length)return new Map;const s=this.groups.userAttribute,a=this.config.mail_attribute||"mail",i=new Map((await this.groups.users(e,[...new Set(t.map(w))],[s,"entryUUID",a,"givenName","sn"])).map(e=>[(f(e,s)||"").toLowerCase(),e]));return new Map(t.flatMap(e=>{const t=i.get(w(e).toLowerCase());if(!t)return[];const r={uuid:f(t,"entryUUID"),username:f(t,s),email:f(t,a),firstName:f(t,"givenName"),lastName:f(t,"sn")};return[[d(e),r]]}))}publishEvent(e,t,s,a,i={}){this.enqueue(e,s,{id:t,...i,actor:a.actor},(new Date).toISOString())}enqueue(e,t,s,a){const i=this.rabbitmq;if(!i)return Promise.resolve();const r=this.publishing.then(async()=>this.publish(i,t,{organizationId:e,...await this.domainOf(e),...s,timestamp:a}));return this.publishing=r.catch(()=>{}),r}async onSyncRequested(t){const malformed=()=>new Error(`${this.name}: malformed sync request ${JSON.stringify(t)}`);if("object"!=typeof t||!t||Array.isArray(t))throw malformed();const{organizationId:s,id:a}=t,valid=e=>void 0===e||"string"==typeof e&&e.length>0;if(!valid(s)||!valid(a)||void 0===s&&void 0!==a)throw malformed();if(void 0===s){for(const t of await this.organizations())await this.rabbitmq.publish(this.exchange,L,{organizationId:t,timestamp:(new Date).toISOString()},{messageId:e()});return}let i=!1;try{await this.groups.checkOrganization(s)}catch(e){if(!(e instanceof y))throw e;if(this.logger.info({plugin:this.name,event:"sync",organization:s,result:e.code}),410!==e.status)return;i=!0}await this.sync(s,a,i)}async organizations(){const e=r(this.spaceBase),t=e.findIndex(e=>e.includes(p)),s=e.slice(0,t),a=[],i=await this.server.ldap.system.search({paged:{pageSize:500},scope:"one",filter:"(objectClass=*)",attributes:["dn"]},e.slice(t+1).join(","));for await(const e of i)for(const{dn:t}of e.searchEntries){const e=c(this.spacePattern,["cn=x",...s,t].join(","));void 0!==e&&a.push(e)}return a}async sync(e,t,s){const a=(new Date).toISOString(),i=s?[]:await this.spaces(e,"(objectClass=*)",t),r=[];void 0===t||i.length||r.push(this.enqueue(e,"deleted",{id:t},a));for(const t of i)r.push(this.enqueue(e,"synced",await this.whole(e,t),a));void 0===t&&r.push(this.enqueue(e,"sync.completed",{spaceIds:i.map(e=>e.id)},a));const n=(await Promise.allSettled(r)).find(e=>"rejected"===e.status);if(n)throw n.reason}async whole(e,t){const s=d(this.groups.userBaseOf(e)),a=await this.orgGroups(e,t.holders.filter(e=>"group"===e.kind).map(e=>e.name),["member"]),i=new Map(a.map(e=>[d(e.dn),{name:f(e,this.groupName)||w(e.dn),members:g(e.member)}])),r=new Map,hold=(e,t)=>{r.has(d(e))||r.set(d(e),{dn:e,role:t})};for(const e of t.holders)if("member"===e.kind)hold(e.dn,e.role);else for(const t of i.get(d(e.dn))?.members??[])d(m(t))===s&&hold(t,e.role);const n=[...r.values()],o=await this.profiles(e,n.map(e=>e.dn));return{id:t.id,name:t.name,members:n.flatMap(({dn:e,role:t})=>{const s=o.get(d(e));return s?[{...s,role:t}]:[]}),groups:t.holders.filter(e=>"group"===e.kind).map(e=>({id:e.name,name:i.get(d(e.dn))?.name??e.name,role:e.role}))}}async domainOf(e){const t=this.config.twake_group_organization_dn;if(!t)return{};const s=e.toLowerCase();let a=this.domains.get(s);if(void 0===a){const i=this.domainAttribute;try{const s=await this.entry(t.replace(p,o(e)),[i]);a=s&&f(s,i)}catch(t){this.logger.warn({plugin:this.name,event:"organizationDomain",organization:e,error:String(t)})}a&&this.domains.set(s,a)}return a?{organizationDomain:a}:{}}get rabbitmq(){return this.config.rabbitmq_url?this.requirePlugin("rabbitmq"):null}async publish(t,s,a){const i={plugin:this.name,exchange:this.exchange,routingKey:`twake.space.${s}`,messageId:e()};if(!await t.getRawClient())throw this.logger.error({...i,result:"no broker"}),new Error(`${this.name}: no broker for ${i.routingKey}`);try{await t.publish(i.exchange,i.routingKey,a,{messageId:i.messageId})}catch(e){throw this.logger.error({...i,result:"error",error:String(e)}),e}this.logger.info({...i,result:"published"})}async rolesOf(e,t,s,a){const{filter:i,roleIn:r}=await this.userIn(e,t,void 0,a);let n=await this.spaces(e,i,void 0,void 0,s);if(a&&"id"in a){const e=a.id.toLowerCase();n=n.filter(t=>t.id.toLowerCase()!==e),a.space&&n.push(a.space)}const o=new Map(n.flatMap(e=>{const t=r(e);return t?[[e.id,t]]:[]}));if(a&&"group"in a&&a.members.has(d(t)))for(const{id:e,role:t}of a.spaces??[]){const s=o.get(e);(!s||C.indexOf(t)>C.indexOf(s))&&o.set(e,t)}return o}async writeRoles(e,t){const s=this.userRole,i=[...t].map(([e,t])=>`${e}:${t}`),r=new Set(i.map(e=>e.toLowerCase()));for(let t=0;;t++)try{const[t]=(await this.server.ldap.system.search({paged:!1,scope:"base",attributes:[s]},e)).searchEntries;if(!t)return;const a=g(I(t,s)),n=new Set(a.map(e=>e.toLowerCase())),o=a.filter(e=>A.test(e)&&!r.has(e.toLowerCase())),h=i.filter(e=>!n.has(e.toLowerCase()));return(o.length||h.length)&&await this.server.ldap.system.modify(e,{...o.length&&{delete:{[s]:o}},...h.length&&{add:{[s]:h}}}),new Map(a.flatMap(e=>{const t=A.exec(e)?.[1].toLowerCase();return t?[[e.slice(0,e.lastIndexOf(":")),t]]:[]}))}catch(e){const s=a(e);if(32===s)return;if(16!==s&&20!==s||t>=2)throw e}}async groupDns(e,t,s){const a=new Map((await this.orgGroups(e,t,[],s)).map(e=>[(f(e,this.groups.cn)||"").toLowerCase(),e.dn]));if(t.some(e=>!a.has(e.toLowerCase())))throw b("group");return a}async add(e,t,s,a,i){await this.again(()=>this.addOnce(e,t,s,a,i))}async addOnce(e,t,s,a,i){const r=await this.space(t,e.params.spaceId,e),n=await i(),o=new Map(r.holders.filter(e=>e.kind===s).map(e=>[e.name.toLowerCase(),e.role])),h=[];for(const[e,t]of n){const i=o.get(e);if(void 0===i)h.push(t);else if(i!==a)throw"member"===s?conflict(`${e} is already a member, as ${i}`,"MEMBER_EXISTS"):conflict(`${e} is already linked, as ${i}`,"GROUP_ALREADY_LINKED")}h.length&&await this.write(t,r.id,{add:{[this.roleAttributes[a]]:h}},e)}async change(e,t,s,a){await this.again(()=>this.changeOnce(e,t,s,a))}async changeOnce(e,t,s,a){const i=await this.space(t,e.params.spaceId,e),r="member"===s?e.params.userId:e.params.groupId,n=i.holders.find(e=>e.kind===s&&e.name.toLowerCase()===r.toLowerCase());if(!n)throw b(s);if(1===n.roles.length&&n.role===a)return;const o="member"===s&&"admin"===n.role&&"admin"!==a;if(o&&!i.holders.some(e=>e!==n&&isAdmin(e)))throw lastAdmin();const h=Object.fromEntries(n.roles.filter(e=>e!==a).map(e=>[this.roleAttributes[e],n.dn])),c=a&&!n.roles.includes(a)?{[this.roleAttributes[a]]:n.dn}:void 0;if(await this.write(t,i.id,{delete:h,add:c},e),o&&!(await this.space(t,i.id,e)).holders.some(isAdmin))throw await this.write(t,i.id,{add:h,delete:c},e),lastAdmin()}async listRoute(e,t,s){const a=this.groups.page(e,q);let i="(cn=*)";a.search&&(i=`(&${i}(${this.displayName}=*${h(a.search)}*))`);const r=e.query.user;let n;if(void 0!==r){if("string"!=typeof r||!r)throw v("user must be a username");const[t]=(await this.liveUsers(s,[r],e)).values(),a=await this.userIn(s,t,e);i=`(&${i}${a.filter})`,n=a.roleIn}const o=this.groups.sorted((await this.spaces(s,i,void 0,e)).map(e=>({...this.spaceOf(s,e),...n&&{role:n(e)}})),a.sortBy,a.desc);t.json({organizationId:s,spaces:o.slice(a.offset,a.offset+a.limit),pagination:{page:a.page,limit:a.limit,total:o.length,totalPages:Math.ceil(o.length/a.limit)}})}async createRoute(t,s,a){const i=k(t);if(!O(i.name))throw v($);const r=this.groups.maxPage,n=holdersOf(i.members,"username",r),o=holdersOf(i.groups??[],"id",r);if(!n.some(e=>"admin"===e.role))throw v("A space needs an admin among its members","ADMIN_REQUIRED");const[h,c]=await Promise.all([this.liveUsers(a,n.map(e=>e.name),t),this.groupDns(a,o.map(e=>e.name),t)]),d=e(),l={objectClass:this.config.twake_space_class||["top","twakeSpace"],cn:d,[this.displayName]:i.name};for(const[e,t]of[[n,h],[o,c]])for(const{name:s,role:a}of e){const e=this.roleAttributes[a];l[e]=[...g(l[e]),t.get(s.toLowerCase())]}await this.server.ldap.add(this.spaceDn(a,d),l,t),s.status(201).json(this.spaceOf(a,await this.space(a,d,t)))}}export{C as SPACE_ROLES,TwakeSpaces as default};
1
+ import{randomUUID as e}from"node:crypto";import t from"../../abstract/plugin.js";import{BadRequestError as s}from"../../lib/errors.js";import{extractLdapCode as a}from"../../lib/ldapCodes.js";import{currentOperation as i}from"../../lib/operation.js";import{parseDn as r,unescapeDnValue as n,escapeDnValue as o,rdnValue as h,escapeLdapFilter as c}from"../../lib/utils.js";import{organizationIn as d,dnKey as l,movedInto as p,ORG as u,branchPattern as g,withOrg as m,values as f,parentOf as w,read as b,notFound as y,RouteError as v,invalid as k,bodyOf as O,isDisplayName as $,NAME_RULE as _}from"./groups.js";import{isTombstone as I,valueOf as S,lifecycleAttributes as C}from"./lifecycleAttributes.js";const M=["viewer","editor","admin"],isRole=e=>M.includes(e),L=[...M].reverse(),q="twake.space.sync.requested",j=["name"],A=["uid","displayName","mail","jobTitle","role"],z=new RegExp(`:(${M.join("|")})$`,"i"),conflict=(e,t)=>new v(409,e,t),lastAdmin=()=>conflict("A space keeps at least one admin","LAST_ADMIN"),isAdmin=e=>"member"===e.kind&&"admin"===e.role;function roleOf(e){if(!isRole(e.role))throw k(`role must be one of: ${M.join(", ")}`);return e.role}function checkLength(e,t,s){if(e.length>s)throw k(`${t} cannot exceed ${s} entries per request`)}function namesOf(e,t,s){if(!Array.isArray(e)||0===e.length||!e.every(e=>"string"==typeof e&&e.length>0))throw k(`${t} must be a non-empty array of non-empty strings`);return checkLength(e,t,s),[...new Map(e.map(e=>[e.toLowerCase(),e])).values()]}function holdersOf(e,t,s){const a="id"===t?"groups":"members";if(!Array.isArray(e))throw k(`${a} must be an array of { ${t}, role }`);checkLength(e,a,s);const i=new Set;return e.map(e=>{const{[t]:s,role:r}=e??{};if("string"!=typeof s||0===s.length||!isRole(r))throw k(`${a} must be an array of { ${t}, role }, role one of: ${M.join(", ")}`);if(i.has(s.toLowerCase()))throw k(`${s} is listed twice in ${a}`);return i.add(s.toLowerCase()),{name:s,role:r}})}class TwakeSpaces extends t{constructor(e){if(super(e),this.name="twakeSpaces",this.roles=["api"],this.dependencies={twakeGroups:"core/twake/groups",onLdapChange:"core/ldap/onChange"},this.domains=new Map,this.following=Promise.resolve(),this.publishing=Promise.resolve(),this.unlinking=new Map,this.announced=new Map,this.handing=new Map,this.hooks={ldapaddrequest:([e,t,s])=>{for(const s of Object.values(this.roleAttributes))this.checkHolders(e,S(t,s));return[e,t,s]},ldapmodifyrequest:([e,t,s,a])=>{for(const s of Object.values(this.roleAttributes))for(const a of[t.add,t.replace])a&&this.checkHolders(e,S(a,s));return[e,t,s,a]},ldaprenamerequest:async([e,t,a])=>{const i=p(this.spacePattern,e,t);if(void 0===i)return[e,t,a];const r=Object.values(this.roleAttributes),n=await this.entry(e,r);if(n&&r.some(e=>void 0!==this.foreign(i,S(n,e))))throw new s(`The space holds users or groups of another organization than ${i}`);return[e,t,a]},ldapdeleterequest:async([e,t])=>{const s=i();if(void 0!==s)for(const t of[e].flat()){const e=d(this.userPattern,t);if(void 0!==e){if(this.tombstone?.keeps(t))continue;const a=await this.administered(e,t);if(!a.length)continue;this.handing.has(s)||this.handing.set(s,new Map),this.handing.get(s).set(l(t),{org:e,ids:a})}else if(this.rabbitmq){const e=await this.deletedGroup(t);e&&this.unlinking.set(l(t),{...e,op:s})}}return[e,t]},ldapdeletedone:(e,t={})=>{const s=i(),a=void 0===s?void 0:this.handing.get(s);for(const s of[e].flat()){const e=this.unlinking.get(l(s));e&&delete e.op;const i=a?.get(l(s));i&&this.queue(s,()=>this.handOver(i.org,s,i.ids,t))}},ldapdeleteend:e=>{this.handing.delete(e);for(const[t,s]of this.unlinking)if(s.op===e){this.unlinking.delete(t);for(const{id:e,context:t,fields:a}of s.owed??[])this.publishEvent(s.org,e,"group.unlinked",t,a)}},onLdapEntryChange:(e,t,s,a)=>{const i=this.orgPattern?.exec(r(e).join(","))?.groups;i&&this.rabbitmq&&(this.publishing=this.publishing.then(()=>{this.domains.delete(n(i.org).toLowerCase())})),this.follows&&this.queue(e,()=>this.follow(e,t,s,a));const o=d(this.userPattern,e);void 0!==o&&this.lifecycle.deleted&&t&&s&&!I(t,this.lifecycle)&&I(s,this.lifecycle)&&this.queue(e,async()=>this.handOver(o,e,await this.administered(o,e),a))}},this.spaceBase=this.config.twake_space_base||"",2!==this.spaceBase.split(u).length)throw new Error(`${this.name}: --twake-space-base must hold ${u} once`);this.spacePattern=g(this.spaceBase),this.userPattern=g(this.config.twake_group_user_base),this.lifecycle=C(this.config),this.displayName=this.config.twake_space_display_name_attribute||"twakeDisplayName",this.roleAttributes={admin:this.config.twake_space_admin_attribute||"twakeSpaceAdmin",editor:this.config.twake_space_editor_attribute||"twakeSpaceEditor",viewer:this.config.twake_space_viewer_attribute||"twakeSpaceViewer"},this.userRole=this.config.twake_space_user_role_attribute||"",this.exchange=this.config.twake_space_exchange||"space",this.domainAttribute=this.config.twake_instance_organization_domain_attribute||"twakeDomain",this.config.twake_group_organization_dn&&(this.orgPattern=g(this.config.twake_group_organization_dn)),this.config.rabbitmq_url&&(this.dependencies.rabbitmq="core/rabbitmq")}get follows(){return Boolean(this.userRole||this.config.rabbitmq_url)}get groups(){return this.server.loadedPlugins.twakeGroups}get tombstone(){return this.server.loadedPlugins.twakeTombstone}async assertComposition(){if(this.config.rabbitmq_url&&!await(this.rabbitmq?.getRawClient()))throw new Error(`${this.name}: RabbitMQ at --rabbitmq-url cannot be reached`);if(this.config.rabbitmq_url&&this.orgPattern&&!(await this.server.ldap.schemaIndex()).getAttributeType(this.domainAttribute))throw new Error(`${this.name}: the directory schema defines no ${this.domainAttribute}; load a schema that does, or set --twake-instance-organization-domain-attribute`);if(!this.follows||!this.server.loadedPlugins.trash)return;const type=e=>e.split("=")[0].toLowerCase(),e=[this.config.twake_group_base||"",this.spaceBase].map(e=>{const t=r(e).reverse();return e=>r(e).reverse().slice(0,t.length).every((e,s)=>t[s].includes(u)?type(e)===type(t[s]):l(e)===l(t[s]))}),t=String(this.config.trash_watched_bases||"").split(";").map(e=>e.trim()).filter(Boolean);if(0===t.length||t.some(t=>e.some(e=>e(t))))throw new Error(`${this.name}: core/ldap/trash watches the organization groups or spaces, and the deletion of one would not be followed: no user role value or event would say it went. Leave the group and space branches out of --trash-watched-bases`)}queue(e,t){this.following=this.following.then(()=>t().catch(t=>{this.logger.error({plugin:this.name,event:"follow",dn:e,error:String(t)})}))}organizationOf(e){return d(this.spacePattern,e)}spaceBaseOf(e){return m(this.spaceBase,e)}spaceDn(e,t){return`cn=${o(t)},${this.spaceBaseOf(e)}`}async entry(e,t){try{return(await this.server.ldap.system.search({paged:!1,scope:"base",attributes:t},e)).searchEntries[0]}catch(e){if(32===a(e))return;throw e}}checkHolders(e,t){const a=this.organizationOf(e);if(!a)return;const i=this.foreign(a,t);if(void 0!==i)throw new s(`${i} is neither a user nor a group of organization ${a}`)}foreign(e,t){const s=[this.groups.userBaseOf(e),this.groups.groupBaseOf(e)].map(l);return f(t).find(e=>!s.includes(l(w(e))))}async api(e){const t=`${this.config.api_prefix}/v1/organizations/:id/spaces`,route=(e,t)=>this.groups.organizationRoute(e,t,this.name),done=e=>{e.json({success:!0})},spaceId=e=>e.params.spaceId;e.get(t,route("listing spaces",(e,t,s)=>this.listRoute(e,t,s))),e.post(t,route("creating space",(e,t,s)=>this.createRoute(e,t,s))),e.get(`${t}/:spaceId`,route("getting space",async(e,t,s)=>{t.json(this.spaceOf(s,await this.space(s,spaceId(e),e)))})),e.patch(`${t}/:spaceId`,route("renaming space",async(e,t,s)=>{const a=O(e),i=Object.keys(a).filter(e=>"name"!==e);if(i.length)throw k(`Only "name" can be updated; unsupported field(s): ${i.join(", ")}`);if(!$(a.name))throw k(_);await this.write(s,spaceId(e),{replace:{[this.displayName]:a.name}},e),done(t)})),e.delete(`${t}/:spaceId`,route("deleting space",async(e,t,s)=>{try{await this.server.ldap.delete(this.spaceDn(s,spaceId(e)),e)}catch(e){if(32===a(e))throw y("space");throw e}done(t)})),e.get(`${t}/:spaceId/members`,route("listing space members",async(e,t,s)=>{const a=this.groups.page(e,A),i=await this.space(s,spaceId(e),e),r=i.holders.filter(e=>"member"===e.kind),n=new Map(r.map(e=>[e.name.toLowerCase(),e.role]));t.json({organizationId:s,id:i.id,...await this.groups.memberPage(a,s,r.map(e=>e.name),e=>({role:n.get(e.toLowerCase())}),e)})})),e.post(`${t}/:spaceId/members`,route("adding space members",async(e,t,s)=>{const a=O(e),i=namesOf(a.usernames,"usernames",this.groups.maxPage);await this.add(e,s,"member",roleOf(a),()=>this.liveUsers(s,i,e)),done(t)})),e.patch(`${t}/:spaceId/members/:userId`,route("changing space member role",async(e,t,s)=>{await this.change(e,s,"member",roleOf(O(e))),done(t)})),e.delete(`${t}/:spaceId/members/:userId`,route("removing space member",async(e,t,s)=>{await this.change(e,s,"member"),done(t)})),e.get(`${t}/:spaceId/groups`,route("listing space groups",async(e,t,s)=>{const a=await this.space(s,spaceId(e),e),i=a.holders.filter(e=>"group"===e.kind),r=new Map((await this.orgGroups(s,i.map(e=>e.name),[],e)).map(e=>[l(e.dn),this.groups.groupOf(s,e).displayName])),n=i.map(({name:e,role:t,dn:s})=>({id:e,name:r.get(l(s))??e,role:t})).sort((e,t)=>e.name.localeCompare(t.name));t.json({organizationId:s,id:a.id,groups:n})})),e.post(`${t}/:spaceId/groups`,route("linking space groups",async(e,t,s)=>{const a=O(e),i=namesOf(a.groupIds,"groupIds",this.groups.maxPage);await this.add(e,s,"group",roleOf(a),()=>this.groupDns(s,i,e)),done(t)})),e.patch(`${t}/:spaceId/groups/:groupId`,route("changing space group role",async(e,t,s)=>{await this.change(e,s,"group",roleOf(O(e))),done(t)})),e.delete(`${t}/:spaceId/groups/:groupId`,route("unlinking space group",async(e,t,s)=>{await this.change(e,s,"group"),done(t)})),await(this.rabbitmq?.subscribe(this.exchange,q,this.config.twake_space_sync_queue||`${q}.ldap-rest`,e=>this.onSyncRequested(e)))}spaceOf(e,t){return{id:t.id,name:t.name,organizationId:e,members:t.holders.filter(e=>"member"===e.kind).map(({name:e,role:t})=>({username:e,role:t})),groups:t.holders.filter(e=>"group"===e.kind).map(({name:e,role:t})=>({id:e,role:t}))}}entryOf(e,t,s){const a=new Map([[l(this.groups.userBaseOf(e)),"member"],[l(this.groups.groupBaseOf(e)),"group"]]),i=b(t,"cn")||"",r=new Map;for(const e of L)for(const i of f(S(t,this.roleAttributes[e]))){const t=l(i),n=a.get(l(w(i)));if(!n||s.has(t))continue;const o=r.get(t);o?o.roles.push(e):r.set(t,{kind:n,name:h(i),role:e,roles:[e],dn:i})}return{id:i,name:b(t,this.displayName)??i,holders:[...r.values()]}}async spaces(e,t,s,i,r){const[n,o]=await Promise.all([(async()=>{try{return(await this.server.ldap.search({paged:!1,scope:void 0===s?"one":"base",filter:t,attributes:["cn",this.displayName,...Object.values(this.roleAttributes)]},void 0===s?this.spaceBaseOf(e):this.spaceDn(e,s),i)).searchEntries}catch(e){if(32===a(e))return[];throw e}})(),r??this.hidden(e)]);return n.map(t=>this.entryOf(e,t,o))}async hidden(e){return new Set((await this.groups.tombstonesOf(e)).map(l))}holding(e){return`(|${e.flatMap(e=>Object.values(this.roleAttributes).map(t=>`(${t}=${c(e)})`)).join("")})`}async space(e,t,s){const[a]=await this.spaces(e,"(objectClass=*)",t,s);if(!a)throw y("space");return a}async write(e,t,s,i){try{await this.server.ldap.modify(this.spaceDn(e,t),s,i)}catch(e){if(32===a(e))throw y("space");throw e}}async again(e){for(let t=0;;t++)try{return await e()}catch(e){const s=a(e);if(16!==s&&20!==s||t>=2)throw e}}async liveUsers(e,t,s){const a=this.groups.userAttribute,i=new Map((await this.groups.users(e,t,[a],s)).map(e=>[(b(e,a)||"").toLowerCase(),e.dn]));if(t.some(e=>!i.has(e.toLowerCase())))throw y("user");return i}get groupName(){return this.config.twake_group_display_name_attribute||"twakeDisplayName"}async orgGroups(e,t,s=[],a){const i=this.groups.cn,r=[];for(let n=0;n<t.length;n+=this.groups.maxPage){const o=t.slice(n,n+this.groups.maxPage);r.push(...await this.groups.groups(e,`(|${o.map(e=>`(${i}=${c(e)})`).join("")})`,[i,this.groupName,...s],a))}return r}async follow(e,t,s,a){let i=this.organizationOf(e);if(void 0!==i){const[e,r]=[t,s].map(e=>e?this.entryOf(i,e,new Set):void 0),n=[...new Set([e,r].flatMap(e=>e?[e.id]:[]))],o=[e,r].map(e=>new Map(e?.holders.map(e=>[l(e.dn),e.role]))),h=[e,r].flatMap(e=>e?.holders??[]).filter(e=>n.length>1||o[0].get(l(e.dn))!==o[1].get(l(e.dn))),c=new Set(h.filter(e=>"group"===e.kind).map(e=>e.name)),d=await this.orgGroups(i,[...c],["member"]),p=(r??e).id,u=await this.settle(i,[...h.filter(e=>"member"===e.kind).map(e=>e.dn),...d.flatMap(e=>f(e.member))],{id:p,space:e},await this.hidden(i));return void(this.rabbitmq&&await this.announce(i,u,a,n,{was:e,is:r,linked:d}))}if(i=this.groups.organizationOf(e),void 0===i||!t)return;const r=f(t.member),n=f(s?.member),[o,h]=[r,n].map(e=>new Set(e.map(l))),c=s?void 0:this.unlinking.get(l(e));c&&this.unlinking.delete(l(e));const d=await this.hidden(i),p=await this.settle(i,[...r.filter(e=>!h.has(l(e))),...n.filter(e=>!o.has(l(e)))],{group:e,members:o,spaces:c?.spaces},d);if(!this.rabbitmq||!c&&!p.length)return;const u=(await this.spaces(i,this.holding([e]),void 0,void 0,d)).map(e=>e.id);c&&this.unlinked(e,c,u,a),await this.announce(i,p,a,[...c?.spaces.map(e=>e.id)??[],...u])}async administered(e,t){try{const s=`(${this.roleAttributes.admin}=${c(t)})`;return(await this.spaces(e,s)).map(e=>e.id)}catch(e){return this.logger.error({plugin:this.name,event:"handOver",dn:t,error:String(e)}),[]}}async handOver(e,t,s,a){for(const i of s)try{await this.again(()=>this.handOverOnce(e,t,i,a))}catch(e){this.logger.error({plugin:this.name,event:"handOver",space:i,error:String(e)})}}async handOverOnce(e,t,s,a){const i=l(t),[r]=await this.spaces(e,"(objectClass=*)",s),n=r?.holders.filter(e=>"member"===e.kind&&l(e.dn)!==i)??[];if(!r||n.some(e=>"admin"===e.role))return;if(!n.length)return void await this.server.ldap.system.delete(this.spaceDn(e,s),void 0,{context:a});const o=n.some(e=>"editor"===e.role)?"editor":"viewer",h=n.filter(e=>e.role===o).map(e=>e.dn);await this.server.ldap.system.modify(this.spaceDn(e,s),{delete:{[this.roleAttributes[o]]:h},add:{[this.roleAttributes.admin]:h}},void 0,{context:a})}async deletedGroup(e){const t=this.groups.organizationOf(e);if(void 0!==t)try{const s=(await this.spaces(t,this.holding([e]))).flatMap(t=>{const s=t.holders.find(t=>l(t.dn)===l(e));return s?[{id:t.id,role:s.role}]:[]});if(!s.length)return;const[a]=await this.orgGroups(t,[h(e)]),i=h(e);return{org:t,id:i,name:a&&b(a,this.groupName)||i,spaces:s}}catch(t){return void this.logger.error({plugin:this.name,event:"deleting",dn:e,error:String(t)})}}unlinked(e,t,s,a){const{org:i,id:r,name:n,spaces:o}=t,h=new Set(s.map(e=>e.toLowerCase()));for(const{id:t,role:s}of o){const o=l(this.spaceDn(i,t));h.has(t.toLowerCase())&&(this.announced.has(o)||this.announced.set(o,new Set),this.announced.get(o).add(l(e))),this.publishEvent(i,t,"group.unlinked",a,{groups:[{id:r,name:n,role:s}]})}}async settle(e,t,s,a){const i=l(this.groups.userBaseOf(e)),r=new Map(t.filter(e=>l(w(e))===i).map(e=>[l(e),e])),n=[];for(const[t,i]of r)if(!a.has(t))try{const t=await this.rolesOf(e,i,a),r=this.userRole?await this.writeRoles(i,t):await this.rolesOf(e,i,a,s);r&&n.push({dn:i,before:r,after:t})}catch(e){this.logger.error({plugin:this.name,event:"settle",dn:i,error:String(e)})}return n}async userIn(e,t,s,a){const i=(await this.groups.groups(e,`(member=${c(t)})`,[this.groups.cn],s)).map(e=>l(e.dn)),r=new Set([l(t),...i]),n=[t,...i];if(a&&"group"in a){const e=l(a.group);r.delete(e),a.members.has(l(t))&&r.add(e),n.push(a.group)}return{filter:this.holding(n),roleIn:e=>e.holders.find(e=>r.has(l(e.dn)))?.role}}async announce(e,t,s,a,i){const r=new Set;if(i){const{was:a,is:n,linked:o}=i,c=(n??a).id,d=new Map(o.map(e=>[l(e.dn),b(e,this.groupName)||h(e.dn)])),groupOf=e=>({id:e.name,name:d.get(l(e.dn))??e.name,role:e.role}),publish=(t,a)=>this.publishEvent(e,c,t,s,a),p=l(this.spaceDn(e,c)),u=new Set;for(const t of[a,n]){const s=t&&l(this.spaceDn(e,t.id));for(const e of s&&this.announced.get(s)||[])u.add(e);s&&this.announced.delete(s)}if(a&&n){a.name!==n.name&&publish("updated",{name:n.name});const e=new Map(a.holders.filter(e=>"group"===e.kind).map(e=>[l(e.dn),e]));for(const t of n.holders.filter(e=>"group"===e.kind)){const s=e.get(l(t.dn));e.delete(l(t.dn)),s?.role!==t.role&&publish(s?"group.role.changed":"group.linked",{groups:[groupOf(t)]})}const byDeletion=e=>{if(u.delete(l(e.dn)))return!0;const t=this.unlinking.get(l(e.dn));return!!t?.spaces.some(e=>e.id.toLowerCase()===c.toLowerCase())&&(void 0!==t.op&&(t.owed??(t.owed=[])).push({id:c,context:s,fields:{groups:[groupOf(e)]}}),!0)};for(const t of e.values())byDeletion(t)||publish("group.unlinked",{groups:[groupOf(t)]});const[t,i]=[a,n].map(e=>new Set(e.holders.map(e=>l(e.dn)))),r=[...u].filter(e=>t.has(e)&&i.has(e));r.length&&this.announced.set(p,new Set(r))}else if(r.add(c.toLowerCase()),n){const s=t.flatMap(e=>{const t=e.after.get(c);return t?[{dn:e.dn,role:t}]:[]}),a=await this.profiles(e,s.map(e=>e.dn));publish("created",{name:n.name,members:s.flatMap(({dn:e,role:t})=>{const s=a.get(l(e));return s?[{...s,role:t}]:[]}),groups:n.holders.filter(e=>"group"===e.kind).map(groupOf)})}else publish("deleted")}const n=new Set(a.map(e=>e.toLowerCase())),o=t.flatMap(({dn:e,before:t,after:s})=>[...new Set([...t.keys(),...s.keys()])].flatMap(a=>{const[i,o]=[t.get(a),s.get(a)];if(i===o||r.has(a.toLowerCase()))return[];const h=!n.has(a.toLowerCase());if(h&&!this.userRole)return[];return[{dn:e,id:a,event:i?o?"member.role.changed":"member.removed":"member.added",role:o??i,caught:h}]}));if(!o.length)return;const c=await this.profiles(e,o.map(e=>e.dn));for(const{dn:t,id:a,event:i,role:r,caught:n}of o){const o=c.get(l(t));o&&this.publishEvent(e,a,i,n?{}:s,{members:[{...o,role:r}]})}}async profiles(e,t){if(!t.length)return new Map;const s=this.groups.userAttribute,a=this.config.mail_attribute||"mail",i=new Map((await this.groups.users(e,[...new Set(t.map(h))],[s,"entryUUID",a,"givenName","sn"])).map(e=>[(b(e,s)||"").toLowerCase(),e]));return new Map(t.flatMap(e=>{const t=i.get(h(e).toLowerCase());if(!t)return[];const r={uuid:b(t,"entryUUID"),username:b(t,s),email:b(t,a),firstName:b(t,"givenName"),lastName:b(t,"sn")};return[[l(e),r]]}))}publishEvent(e,t,s,a,i={}){this.enqueue(e,s,{id:t,...i,actor:a.actor},(new Date).toISOString())}enqueue(e,t,s,a){const i=this.rabbitmq;if(!i)return Promise.resolve();const r=this.publishing.then(async()=>this.publish(i,t,{organizationId:e,...await this.domainOf(e),...s,timestamp:a}));return this.publishing=r.catch(()=>{}),r}async onSyncRequested(t){const malformed=()=>new Error(`${this.name}: malformed sync request ${JSON.stringify(t)}`);if("object"!=typeof t||!t||Array.isArray(t))throw malformed();const{organizationId:s,id:a}=t,valid=e=>void 0===e||"string"==typeof e&&e.length>0;if(!valid(s)||!valid(a)||void 0===s&&void 0!==a)throw malformed();if(void 0===s){for(const t of await this.organizations())await this.rabbitmq.publish(this.exchange,q,{organizationId:t,timestamp:(new Date).toISOString()},{messageId:e()});return}let i=!1;try{await this.groups.checkOrganization(s)}catch(e){if(!(e instanceof v))throw e;if(this.logger.info({plugin:this.name,event:"sync",organization:s,result:e.code}),410!==e.status)return;i=!0}await this.sync(s,a,i)}async organizations(){const e=r(this.spaceBase),t=e.findIndex(e=>e.includes(u)),s=e.slice(0,t),a=[],i=await this.server.ldap.system.search({paged:{pageSize:500},scope:"one",filter:"(objectClass=*)",attributes:["dn"]},e.slice(t+1).join(","));for await(const e of i)for(const{dn:t}of e.searchEntries){const e=d(this.spacePattern,["cn=x",...s,t].join(","));void 0!==e&&a.push(e)}return a}async sync(e,t,s){const a=(new Date).toISOString(),i=s?[]:await this.spaces(e,"(objectClass=*)",t),r=[];void 0===t||i.length||r.push(this.enqueue(e,"deleted",{id:t},a));for(const t of i)r.push(this.enqueue(e,"synced",await this.whole(e,t),a));void 0===t&&r.push(this.enqueue(e,"sync.completed",{spaceIds:i.map(e=>e.id)},a));const n=(await Promise.allSettled(r)).find(e=>"rejected"===e.status);if(n)throw n.reason}async whole(e,t){const s=l(this.groups.userBaseOf(e)),a=await this.orgGroups(e,t.holders.filter(e=>"group"===e.kind).map(e=>e.name),["member"]),i=new Map(a.map(e=>[l(e.dn),{name:b(e,this.groupName)||h(e.dn),members:f(e.member)}])),r=new Map,hold=(e,t)=>{r.has(l(e))||r.set(l(e),{dn:e,role:t})};for(const e of t.holders)if("member"===e.kind)hold(e.dn,e.role);else for(const t of i.get(l(e.dn))?.members??[])l(w(t))===s&&hold(t,e.role);const n=[...r.values()],o=await this.profiles(e,n.map(e=>e.dn));return{id:t.id,name:t.name,members:n.flatMap(({dn:e,role:t})=>{const s=o.get(l(e));return s?[{...s,role:t}]:[]}),groups:t.holders.filter(e=>"group"===e.kind).map(e=>({id:e.name,name:i.get(l(e.dn))?.name??e.name,role:e.role}))}}async domainOf(e){const t=this.config.twake_group_organization_dn;if(!t)return{};const s=e.toLowerCase();let a=this.domains.get(s);if(void 0===a){const i=this.domainAttribute;try{const s=await this.entry(m(t,e),[i]);a=s&&b(s,i)}catch(t){this.logger.warn({plugin:this.name,event:"organizationDomain",organization:e,error:String(t)})}a&&this.domains.set(s,a)}return a?{organizationDomain:a}:{}}get rabbitmq(){return this.config.rabbitmq_url?this.requirePlugin("rabbitmq"):null}async publish(t,s,a){const i={plugin:this.name,exchange:this.exchange,routingKey:`twake.space.${s}`,messageId:e()};if(!await t.getRawClient())throw this.logger.error({...i,result:"no broker"}),new Error(`${this.name}: no broker for ${i.routingKey}`);try{await t.publish(i.exchange,i.routingKey,a,{messageId:i.messageId})}catch(e){throw this.logger.error({...i,result:"error",error:String(e)}),e}this.logger.info({...i,result:"published"})}async rolesOf(e,t,s,a){const{filter:i,roleIn:r}=await this.userIn(e,t,void 0,a);let n=await this.spaces(e,i,void 0,void 0,s);if(a&&"id"in a){const e=a.id.toLowerCase();n=n.filter(t=>t.id.toLowerCase()!==e),a.space&&n.push(a.space)}const o=new Map(n.flatMap(e=>{const t=r(e);return t?[[e.id,t]]:[]}));if(a&&"group"in a&&a.members.has(l(t)))for(const{id:e,role:t}of a.spaces??[]){const s=o.get(e);(!s||M.indexOf(t)>M.indexOf(s))&&o.set(e,t)}return o}async writeRoles(e,t){const s=this.userRole,i=[...t].map(([e,t])=>`${e}:${t}`),r=new Set(i.map(e=>e.toLowerCase()));for(let t=0;;t++)try{const[t]=(await this.server.ldap.system.search({paged:!1,scope:"base",attributes:[s]},e)).searchEntries;if(!t)return;const a=f(S(t,s)),n=new Set(a.map(e=>e.toLowerCase())),o=a.filter(e=>z.test(e)&&!r.has(e.toLowerCase())),h=i.filter(e=>!n.has(e.toLowerCase()));return(o.length||h.length)&&await this.server.ldap.system.modify(e,{...o.length&&{delete:{[s]:o}},...h.length&&{add:{[s]:h}}}),new Map(a.flatMap(e=>{const t=z.exec(e)?.[1].toLowerCase();return t?[[e.slice(0,e.lastIndexOf(":")),t]]:[]}))}catch(e){const s=a(e);if(32===s)return;if(16!==s&&20!==s||t>=2)throw e}}async groupDns(e,t,s){const a=new Map((await this.orgGroups(e,t,[],s)).map(e=>[(b(e,this.groups.cn)||"").toLowerCase(),e.dn]));if(t.some(e=>!a.has(e.toLowerCase())))throw y("group");return a}async add(e,t,s,a,i){await this.again(()=>this.addOnce(e,t,s,a,i))}async addOnce(e,t,s,a,i){const r=await this.space(t,e.params.spaceId,e),n=await i(),o=new Map(r.holders.filter(e=>e.kind===s).map(e=>[e.name.toLowerCase(),e.role])),h=[];for(const[e,t]of n){const i=o.get(e);if(void 0===i)h.push(t);else if(i!==a)throw"member"===s?conflict(`${e} is already a member, as ${i}`,"MEMBER_EXISTS"):conflict(`${e} is already linked, as ${i}`,"GROUP_ALREADY_LINKED")}h.length&&await this.write(t,r.id,{add:{[this.roleAttributes[a]]:h}},e)}async change(e,t,s,a){await this.again(()=>this.changeOnce(e,t,s,a))}async changeOnce(e,t,s,a){const i=await this.space(t,e.params.spaceId,e),r="member"===s?e.params.userId:e.params.groupId,n=i.holders.find(e=>e.kind===s&&e.name.toLowerCase()===r.toLowerCase());if(!n)throw y(s);if(1===n.roles.length&&n.role===a)return;const o="member"===s&&"admin"===n.role&&"admin"!==a;if(o&&!i.holders.some(e=>e!==n&&isAdmin(e)))throw lastAdmin();const h=Object.fromEntries(n.roles.filter(e=>e!==a).map(e=>[this.roleAttributes[e],n.dn])),c=a&&!n.roles.includes(a)?{[this.roleAttributes[a]]:n.dn}:void 0;if(await this.write(t,i.id,{delete:h,add:c},e),o&&!(await this.space(t,i.id,e)).holders.some(isAdmin))throw await this.write(t,i.id,{add:h,delete:c},e),lastAdmin()}async listRoute(e,t,s){const a=this.groups.page(e,j);let i="(cn=*)";a.search&&(i=`(&${i}(${this.displayName}=*${c(a.search)}*))`);const r=e.query.user;let n;if(void 0!==r){if("string"!=typeof r||!r)throw k("user must be a username");const[t]=(await this.liveUsers(s,[r],e)).values(),a=await this.userIn(s,t,e);i=`(&${i}${a.filter})`,n=a.roleIn}const o=this.groups.sorted((await this.spaces(s,i,void 0,e)).map(e=>({...this.spaceOf(s,e),...n&&{role:n(e)}})),a.sortBy,a.desc);t.json({organizationId:s,spaces:o.slice(a.offset,a.offset+a.limit),pagination:{page:a.page,limit:a.limit,total:o.length,totalPages:Math.ceil(o.length/a.limit)}})}async createRoute(t,s,a){const i=O(t);if(!$(i.name))throw k(_);const r=this.groups.maxPage,n=holdersOf(i.members,"username",r),o=holdersOf(i.groups??[],"id",r);if(!n.some(e=>"admin"===e.role))throw k("A space needs an admin among its members","ADMIN_REQUIRED");const[h,c]=await Promise.all([this.liveUsers(a,n.map(e=>e.name),t),this.groupDns(a,o.map(e=>e.name),t)]),d=e(),l={objectClass:this.config.twake_space_class||["top","twakeSpace"],cn:d,[this.displayName]:i.name};for(const[e,t]of[[n,h],[o,c]])for(const{name:s,role:a}of e){const e=this.roleAttributes[a];l[e]=[...f(l[e]),t.get(s.toLowerCase())]}await this.server.ldap.add(this.spaceDn(a,d),l,t),s.status(201).json(this.spaceOf(a,await this.space(a,d,t)))}}export{M as SPACE_ROLES,TwakeSpaces as default};
@@ -363,10 +363,12 @@ declare class ldapActions {
363
363
  * the directory applies both, and only one would have been judged.
364
364
  *
365
365
  * Without the schema, the case is still set, and a name given as an OID,
366
- * which nothing here could resolve, is refused.
366
+ * which nothing here could resolve, is refused. So is, with or without
367
+ * it, a name that is neither a descriptor nor a numeric OID.
367
368
  *
368
369
  * @param entry attributes of an add, or one part of a modify
369
- * @throws BadRequestError on an option, or on one attribute named twice
370
+ * @throws BadRequestError on an option, on a name that describes no
371
+ * attribute, or on one attribute named twice
370
372
  */
371
373
  canonicalAttributes(entry: AttributesList | undefined): Promise<void>;
372
374
  /**
@@ -100,6 +100,12 @@ export declare function escapeLdapFilter(value: string): string;
100
100
  * ```
101
101
  */
102
102
  export declare function substringSearchFilter(match: string, attribute: string): string;
103
+ /**
104
+ * `pattern` with every `placeholder` replaced by `value`, taken literally:
105
+ * `String.replace` would expand `$&`, `$'`, `` $` `` and `$$` in a value
106
+ * that a request chose.
107
+ */
108
+ export declare function fillPattern(pattern: string, placeholder: string, value: string): string;
103
109
  /**
104
110
  * Escape special characters in LDAP DN attribute values according to RFC 4514
105
111
  * Prevents LDAP injection attacks by escaping characters that have special meaning in DNs
@@ -14,6 +14,8 @@ export declare class ScimError extends HttpError {
14
14
  scimType?: ScimErrorType;
15
15
  constructor(status: number, detail: string, scimType?: ScimErrorType);
16
16
  }
17
+ /** The kind of a client value, for an error that quotes none of it */
18
+ export declare const kindOf: (v: unknown) => string;
17
19
  export declare const scimInvalidFilter: (detail?: string) => ScimError;
18
20
  export declare const scimInvalidPath: (detail?: string) => ScimError;
19
21
  export declare const scimInvalidValue: (detail?: string) => ScimError;
@@ -120,7 +120,8 @@ export interface BulkRequest {
120
120
  Operations: BulkOperationRequest[];
121
121
  }
122
122
  export interface BulkOperationResponse {
123
- method: 'POST' | 'PUT' | 'PATCH' | 'DELETE';
123
+ /** Absent when the operation was not even an object */
124
+ method?: 'POST' | 'PUT' | 'PATCH' | 'DELETE';
124
125
  bulkId?: string;
125
126
  version?: string;
126
127
  location?: string;
@@ -11,6 +11,7 @@ import { type LifecycleAttributes } from './lifecycleAttributes';
11
11
  */
12
12
  export declare const read: (entry: AttributesList, attribute: string) => string | undefined;
13
13
  export declare const ORG = "{org}";
14
+ export declare function withOrg(pattern: string, org: string): string;
14
15
  /** A refusal the routes answer as `{ error, code }`. */
15
16
  export declare class RouteError extends Error {
16
17
  readonly status: number;
@@ -36,8 +37,6 @@ export declare function values(value: AttributeValue | undefined): string[];
36
37
  /** A DN spelled one way, to compare two spellings of it. */
37
38
  export declare function dnKey(dn: string): string;
38
39
  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
40
  /**
42
41
  * The entries one level under a DN pattern holding `{org}`, the organization
43
42
  * captured as one RDN value: `(?:\\.|[^,])+` keeps an escaped comma inside it.
@@ -643,8 +643,8 @@ Extends `core/ldap/groups`, whose options it reads too.
643
643
 
644
644
  | CLI | Env | Default | Description |
645
645
  | --------------------------------------------- | ---------------------------------------------- | ------------------ | ----------------------------------------------------- |
646
- | `--twake-group-base` | `DM_TWAKE_GROUP_BASE` | | Group branch of an organization, with `{org}` |
647
- | `--twake-group-user-base` | `DM_TWAKE_GROUP_USER_BASE` | | User branch of an organization, with `{org}` |
646
+ | `--twake-group-base` | `DM_TWAKE_GROUP_BASE` | | Group branch of an organization, holding `{org}` once |
647
+ | `--twake-group-user-base` | `DM_TWAKE_GROUP_USER_BASE` | | User branch of an organization, holding `{org}` once |
648
648
  | `--twake-group-organization-dn` | `DM_TWAKE_GROUP_ORGANIZATION_DN` | | Organization entry, with `{org}` (empty: not checked) |
649
649
  | `--twake-group-organization-status-attribute` | `DM_TWAKE_GROUP_ORGANIZATION_STATUS_ATTRIBUTE` | `twakeOrgStatus` | Organization status attribute |
650
650
  | `--twake-group-organization-deleted-value` | `DM_TWAKE_GROUP_ORGANIZATION_DELETED_VALUE` | `deleted` | Status of a deleted organization |
@@ -663,17 +663,17 @@ organization entry, and `core/ldap/onChange`; with `--rabbitmq-url`, it
663
663
  requires `core/rabbitmq` and reads the organization domain from
664
664
  `--twake-instance-organization-domain-attribute`.
665
665
 
666
- | CLI | Env | Default | Description |
667
- | -------------------------------------- | --------------------------------------- | -------------------------------------- | -------------------------------------------------- |
668
- | `--twake-space-base` | `DM_TWAKE_SPACE_BASE` | | Space branch of an organization, with `{org}` |
669
- | `--twake-space-class` | `DM_TWAKE_SPACE_CLASS` | `top,twakeSpace` | Object classes of a space |
670
- | `--twake-space-display-name-attribute` | `DM_TWAKE_SPACE_DISPLAY_NAME_ATTRIBUTE` | `twakeDisplayName` | Space name attribute |
671
- | `--twake-space-admin-attribute` | `DM_TWAKE_SPACE_ADMIN_ATTRIBUTE` | `twakeSpaceAdmin` | DNs holding the admin role |
672
- | `--twake-space-editor-attribute` | `DM_TWAKE_SPACE_EDITOR_ATTRIBUTE` | `twakeSpaceEditor` | DNs holding the editor role |
673
- | `--twake-space-viewer-attribute` | `DM_TWAKE_SPACE_VIEWER_ATTRIBUTE` | `twakeSpaceViewer` | DNs holding the viewer role |
674
- | `--twake-space-user-role-attribute` | `DM_TWAKE_SPACE_USER_ROLE_ATTRIBUTE` | | User attribute holding `<space id>:<role>`, if set |
675
- | `--twake-space-exchange` | `DM_TWAKE_SPACE_EXCHANGE` | `space` | Topic exchange of the space events |
676
- | `--twake-space-sync-queue` | `DM_TWAKE_SPACE_SYNC_QUEUE` | `twake.space.sync.requested.ldap-rest` | Queue of the space sync requests |
666
+ | CLI | Env | Default | Description |
667
+ | -------------------------------------- | --------------------------------------- | -------------------------------------- | ----------------------------------------------------- |
668
+ | `--twake-space-base` | `DM_TWAKE_SPACE_BASE` | | Space branch of an organization, holding `{org}` once |
669
+ | `--twake-space-class` | `DM_TWAKE_SPACE_CLASS` | `top,twakeSpace` | Object classes of a space |
670
+ | `--twake-space-display-name-attribute` | `DM_TWAKE_SPACE_DISPLAY_NAME_ATTRIBUTE` | `twakeDisplayName` | Space name attribute |
671
+ | `--twake-space-admin-attribute` | `DM_TWAKE_SPACE_ADMIN_ATTRIBUTE` | `twakeSpaceAdmin` | DNs holding the admin role |
672
+ | `--twake-space-editor-attribute` | `DM_TWAKE_SPACE_EDITOR_ATTRIBUTE` | `twakeSpaceEditor` | DNs holding the editor role |
673
+ | `--twake-space-viewer-attribute` | `DM_TWAKE_SPACE_VIEWER_ATTRIBUTE` | `twakeSpaceViewer` | DNs holding the viewer role |
674
+ | `--twake-space-user-role-attribute` | `DM_TWAKE_SPACE_USER_ROLE_ATTRIBUTE` | | User attribute holding `<space id>:<role>`, if set |
675
+ | `--twake-space-exchange` | `DM_TWAKE_SPACE_EXCHANGE` | `space` | Topic exchange of the space events |
676
+ | `--twake-space-sync-queue` | `DM_TWAKE_SPACE_SYNC_QUEUE` | `twake.space.sync.requested.ldap-rest` | Queue of the space sync requests |
677
677
 
678
678
  See [spaces](plugins/integrations/spaces.md).
679
679
 
@@ -137,8 +137,8 @@ for it.
137
137
  describes department groups and requires `twakeDepartmentLink`, which these
138
138
  groups do not have. Groups created through SCIM are written directly and
139
139
  are not checked against this schema.
140
- - `--twake-group-base`, `--twake-group-user-base`: required, each with
141
- `{org}`.
140
+ - `--twake-group-base`, `--twake-group-user-base`: required, each holding
141
+ `{org}` once.
142
142
  - `--twake-group-organization-dn`: the organization entry. Empty means no
143
143
  organization is checked.
144
144
  - `--twake-group-organization-status-attribute` (default `twakeOrgStatus`),
@@ -309,6 +309,16 @@ mapping contains an entry with `scim: "department"`; otherwise the server return
309
309
  `400 invalidFilter`. Same for PATCH: operations on unmapped paths produce
310
310
  `400 invalidPath`.
311
311
 
312
+ A User or Group body is checked against RFC 7643 before anything is written
313
+ and before any hook reads it: `userName`, `displayName` and `id` are
314
+ strings; a simple attribute takes a string, a number or a boolean, or a list
315
+ of them; a complex attribute takes an object whose sub-attributes are scalars
316
+ (or, for a reference such as `manager`, an object whose `value` is one); a
317
+ multi-valued complex attribute such as `emails` takes a list of objects whose
318
+ `value` is a scalar. Anything else is answered
319
+ `400 invalidValue`, as is a PATCH operation that is not an object or whose
320
+ `op` is not a string.
321
+
312
322
  A PATCH `add`, or a `remove` naming values, changes only the values it names:
313
323
  values the caller's read did not show, such as group members an
314
324
  `ldapsearchfilter` hides, stay. Two PATCHes changing different values of
@@ -369,7 +379,8 @@ via `escapeLdapFilter()` — no LDAP injection is possible.
369
379
  | `a eq "x" and b eq "y"` | `(&(a=x)(b=y))` |
370
380
  | `not (a eq "x")` | `(!(a=x))` |
371
381
 
372
- Unknown SCIM attributes raise `400 invalidFilter`.
382
+ Unknown SCIM attributes raise `400 invalidFilter`, as does a filter nested
383
+ more than 32 levels deep (a parenthesis and a `not (` each count one).
373
384
 
374
385
  ## Pagination
375
386
 
@@ -249,7 +249,7 @@ A space reads, strongest role first:
249
249
  The plugin loads [groups](groups.md), and reads its options for the
250
250
  organization's users, groups and organization entry.
251
251
 
252
- - `--twake-space-base`: required, with `{org}`.
252
+ - `--twake-space-base`: required, holding `{org}` once.
253
253
  - `--twake-space-class` (default `top,twakeSpace`): the classes of a space
254
254
  created.
255
255
  - `--twake-space-display-name-attribute` (default `twakeDisplayName`).
@@ -4,6 +4,19 @@ What to check before deploying, newest first. Only releases that need a
4
4
  decision or a configuration change appear here; see the
5
5
  [CHANGELOG](../../CHANGELOG.md) for everything else.
6
6
 
7
+ ## From 1.2.0 to 1.2.1
8
+
9
+ ### `{org}` once in a Twake branch pattern
10
+
11
+ **Who is affected:** a deployment whose `--twake-group-base`,
12
+ `--twake-group-user-base` or `--twake-space-base` holds `{org}` more than
13
+ once. None is known.
14
+
15
+ The server refuses to start on such a pattern. It filled the first `{org}`
16
+ only and read the organization back from the first one, so a second
17
+ occurrence never worked; now that every occurrence is filled, the two
18
+ readings would disagree.
19
+
7
20
  ## From 1.1 to 1.2.0
8
21
 
9
22
  ### The process stops on an uncaught exception
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ldap-rest",
3
- "version": "1.2.0",
3
+ "version": "1.2.1",
4
4
  "description": "Light directory manager",
5
5
  "keywords": [
6
6
  "Identity-Manager"