ldap-rest 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/abstract/ldapFlat.js +1 -1
  2. package/dist/config/args.js +1 -1
  3. package/dist/lib/errors.js +1 -1
  4. package/dist/lib/instanceProviders.js +1 -1
  5. package/dist/lib/ldapActions.js +1 -1
  6. package/dist/plugins/auth/openidconnect.js +1 -1
  7. package/dist/plugins/ldap/groups.js +1 -1
  8. package/dist/plugins/ldap/trash.js +1 -1
  9. package/dist/plugins/scim/filter.js +1 -1
  10. package/dist/plugins/scim/groups.js +1 -1
  11. package/dist/plugins/scim/mapping.js +1 -1
  12. package/dist/plugins/scim/patch.js +1 -1
  13. package/dist/plugins/scim/users.js +1 -1
  14. package/dist/plugins/twake/groups.js +1 -0
  15. package/dist/plugins/twake/instances.js +1 -1
  16. package/dist/plugins/twake/lifecycleAttributes.js +1 -1
  17. package/dist/plugins/twake/lifecycleEvents.js +1 -1
  18. package/dist/plugins/twake/tombstone.js +1 -1
  19. package/dist/src/abstract/ldapFlat.d.ts +34 -6
  20. package/dist/src/config/args.d.ts +12 -0
  21. package/dist/src/lib/errors.d.ts +3 -0
  22. package/dist/src/lib/instanceProviders.d.ts +2 -0
  23. package/dist/src/plugins/auth/openidconnect.d.ts +15 -0
  24. package/dist/src/plugins/ldap/groups.d.ts +5 -0
  25. package/dist/src/plugins/scim/filter.d.ts +2 -1
  26. package/dist/src/plugins/scim/groups.d.ts +1 -0
  27. package/dist/src/plugins/scim/mapping.d.ts +18 -7
  28. package/dist/src/plugins/scim/patch.d.ts +2 -2
  29. package/dist/src/plugins/twake/groups.d.ts +89 -0
  30. package/dist/src/plugins/twake/instances.d.ts +14 -0
  31. package/dist/src/plugins/twake/lifecycleEvents.d.ts +20 -3
  32. package/docs/client-development/api/rest-api.md +32 -2
  33. package/docs/usage/configuration.md +22 -1
  34. package/docs/usage/plugins/README.md +1 -0
  35. package/docs/usage/plugins/auth/oidc.md +19 -0
  36. package/docs/usage/plugins/integrations/README.md +1 -0
  37. package/docs/usage/plugins/integrations/groups.md +225 -0
  38. package/docs/usage/plugins/integrations/instances.md +31 -3
  39. package/docs/usage/plugins/integrations/lifecycle-events.md +38 -5
  40. package/docs/usage/plugins/integrations/scim.md +44 -34
  41. package/docs/usage/plugins/integrations/tombstone.md +2 -10
  42. package/docs/usage/plugins/ldap/flat-generic.md +38 -2
  43. package/docs/usage/plugins/ldap/trash.md +4 -4
  44. package/docs/usage/upgrading.md +56 -0
  45. package/package.json +9 -1
  46. package/static/schemas/twake/organizationGroups.json +32 -0
@@ -1 +1 @@
1
- import{extractLdapCode as e}from"../../lib/ldapCodes.js";import{scimInvalidValue as t,ScimError as r,scimInvalidPath as o,scimNoTarget as i}from"./errors.js";import{DEFAULT_LOCK_ATTRIBUTE as n,DEFAULT_LOCK_VALUE as a,scimPathToLdapAttribute as s,readActive as l}from"./mapping.js";function asValues(e){if(null==e)return[];return(Array.isArray(e)?e:[e]).map(e=>Buffer.isBuffer(e)?e.toString():String(e)).filter(e=>e.length>0)}class WorkingEntry{constructor(e){this.current=e,this.values=new Map,this.touched=new Set,this.whole=new Set}get(e){return this.values.has(e)||this.values.set(e,asValues(this.current[e])),this.values.get(e)}set(e,t){this.whole.add(e),this.write(e,t)}append(e,t){const r=this.get(e);this.write(e,[...r,...t.filter(e=>!r.includes(e))])}drop(e,t){if(!t)return this.set(e,[]);this.write(e,this.get(e).filter(e=>!t.includes(e)))}write(e,t){this.touched.add(e),this.values.set(e,t)}diff(){const e={};for(const t of this.touched){const r=asValues(this.current[t]),o=this.get(t),i=o.filter(e=>!r.includes(e)),n=r.filter(e=>!o.includes(e));if(0!==i.length||0!==n.length)if(this.whole.has(t))if(0!==o.length)0===n.length&&r.length>0?(e.add||(e.add={}),e.add[t]=1===i.length?i[0]:i):(e.replace||(e.replace={}),e.replace[t]=1===o.length?o[0]:o);else{if(0===r.length)continue;e.delete||(e.delete={}),e.delete[t]=""}else i.length>0&&(e.add||(e.add={}),e.add[t]=1===i.length?i[0]:i),n.length>0&&(e.delete||(e.delete={}),e.delete[t]=1===n.length?n[0]:n)}return e}}function normalizeOp(e){const r=e.toLowerCase();if("add"===r||"remove"===r||"replace"===r)return r;throw t(`Unknown patch op '${e}'`)}const u=new Set(["__proto__","constructor","prototype"]),f=/^[A-Za-z_$:][\w$:]*$/;function assertSafeKey(e){if(u.has(e))throw o(`Forbidden attribute name '${e}'`)}function parsePath(e){if("string"!=typeof e||0===e.length||e.length>512)throw o(`Malformed path '${String(e)}'`);let t,r,i;const n=e.indexOf("[");if(n>=0){const a=e.lastIndexOf("]");if(a<=n)throw o(`Malformed path '${e}'`);t=e.slice(0,n),r=e.slice(n+1,a);const s=e.slice(a+1);if(s.length>0){if("."!==s[0])throw o(`Malformed path '${e}'`);i=s.slice(1)}}else{const r=e.indexOf(".");r>=0?(t=e.slice(0,r),i=e.slice(r+1)):t=e}if(!f.test(t))throw o(`Malformed path '${e}'`);if(assertSafeKey(t),null!=i){if(!f.test(i))throw o(`Malformed sub-attribute in '${e}'`);assertSafeKey(i)}return{top:t,filter:r,sub:i}}async function applyOperation(e,r,u){const f=normalizeOp(e.op),p=u.memberAttribute||"member";if(!e.path){if("remove"===f)throw i("PATCH 'remove' requires a path");if(null==e.value||"object"!=typeof e.value||Array.isArray(e.value))throw i("PATCH without path requires an object value");const t=e.value;for(const[o,i]of Object.entries(t))await applyOperation({op:e.op,path:o,value:i},r,u);return}const{top:c,sub:h,filter:d}=parsePath(e.path);if("members"===c){const t=u.resolveMemberRef;if(!t)throw i("Member operations require a member resolver");const asMemberValues=async e=>{const r=Array.isArray(e)?e:[e],o=[];for(const e of r){const r="string"==typeof e?e:"object"==typeof e&&null!=e&&"value"in e?String(e.value):"";if(!r)continue;const i=await t(r);i&&o.push(i)}return o};if("add"===f)return void r.append(p,await asMemberValues(e.value));if("replace"===f){const t=await asMemberValues(e.value);if(0===t.length)return;return void r.set(p,t)}if(!d&&null==e.value)return void r.drop(p);const o=d?/value\s+eq\s+"([^"]+)"/i.exec(d)?.[1]??"":"",n=[...o?await asMemberValues(o):[],...null!=e.value?await asMemberValues(e.value):[]];return void r.drop(p,n)}if("active"===c&&u.supportsActive){if(h||d)throw o(`'active' has no sub-attribute (got '${e.path}')`);const i=u.lockAttribute||n,s="remove"===f||function(e){const r=l(e);if(void 0===r)throw t("PATCH on 'active' requires a value");return r}(e.value);return void(s?r.drop(i):r.set(i,[u.lockValue||a]))}if(d)throw o(`Complex multi-valued filters are only supported on 'members' (got '${e.path}')`);const y=s(h?`${c}.${h}`:c,u.mapping);if(!y)throw o(`Unknown SCIM attribute path '${e.path}'`);if("remove"===f)return void r.drop(y);const v=function(e){if(null!=e)return"string"==typeof e?e:"number"==typeof e||"boolean"==typeof e?String(e):Array.isArray(e)?e.map(e=>{if("string"==typeof e)return e;if("number"==typeof e||"boolean"==typeof e)return String(e);if(e&&"object"==typeof e&&!Array.isArray(e)&&"value"in e&&("string"==typeof e.value||"number"==typeof e.value||"boolean"==typeof e.value))return String(e.value);throw t(`Unsupported array element in PATCH value: ${JSON.stringify(e)}`)}):void 0}(e.value);if(null==v)throw t(`PATCH ${e.op} ${e.path} missing value`);const w=Array.isArray(v)?v:[v];"add"===f?r.append(y,w):r.set(y,w)}async function patchToModifyRequest(e,r){if(!e.Operations||!Array.isArray(e.Operations))throw t("Missing Operations array");const o=new WorkingEntry(r.current);for(const t of e.Operations)await applyOperation(t,o,r);return o.diff()}async function writePatch(t,o){for(let i=1;;i++)try{return await o()}catch(o){if(16!==e(o))throw o;if(2===i)throw new r(409,`${t} changed while this PATCH was being applied; nothing was written. Retry the request.`)}}function applyPatchToResource(e,t){const r={...e};for(const e of t.Operations){const t=normalizeOp(e.op);if(!e.path){if(e.value&&"object"==typeof e.value&&!Array.isArray(e.value))for(const[t,o]of Object.entries(e.value))assertSafeKey(t),r[t]=o;continue}const{top:o,sub:i}=parsePath(e.path);if("remove"!==t)if(i){const t=r[o],n=t&&"object"==typeof t&&!Array.isArray(t)?{...t}:Object.create(null);n[i]=e.value,r[o]=n}else r[o]=e.value;else if(i){const e=r[o];e&&"object"==typeof e&&delete e[i]}else delete r[o]}return r}export{applyPatchToResource,patchToModifyRequest,writePatch};
1
+ import{extractLdapCode as e}from"../../lib/ldapCodes.js";import{scimInvalidValue as t,ScimError as r,scimInvalidPath as o,scimNoTarget as n}from"./errors.js";import{DEFAULT_LOCK_ATTRIBUTE as i,DEFAULT_LOCK_VALUE as a,holdsValue as s,scimPathToLdapAttribute as l,readActive as u}from"./mapping.js";function asValues(e){if(null==e)return[];return(Array.isArray(e)?e:[e]).map(e=>Buffer.isBuffer(e)?e.toString():String(e)).filter(e=>e.length>0)}class WorkingEntry{constructor(e){this.current=e,this.values=new Map,this.touched=new Set,this.whole=new Set}get(e){return this.values.has(e)||this.values.set(e,asValues(this.current[e])),this.values.get(e)}set(e,t){this.whole.add(e),this.write(e,t)}append(e,t){const r=this.get(e);this.write(e,[...r,...t.filter(e=>!r.includes(e))])}drop(e,t){if(!t)return this.set(e,[]);this.write(e,this.get(e).filter(e=>!t.includes(e)))}write(e,t){this.touched.add(e),this.values.set(e,t)}diff(){const e={};for(const t of this.touched){const r=asValues(this.current[t]),o=this.get(t),n=o.filter(e=>!r.includes(e)),i=r.filter(e=>!o.includes(e));if(0!==n.length||0!==i.length)if(this.whole.has(t))if(0!==o.length)0===i.length&&r.length>0?(e.add||(e.add={}),e.add[t]=1===n.length?n[0]:n):(e.replace||(e.replace={}),e.replace[t]=1===o.length?o[0]:o);else{if(0===r.length)continue;e.delete||(e.delete={}),e.delete[t]=""}else n.length>0&&(e.add||(e.add={}),e.add[t]=1===n.length?n[0]:n),i.length>0&&(e.delete||(e.delete={}),e.delete[t]=1===i.length?i[0]:i)}return e}}function normalizeOp(e){const r=e.toLowerCase();if("add"===r||"remove"===r||"replace"===r)return r;throw t(`Unknown patch op '${e}'`)}const f=new Set(["__proto__","constructor","prototype"]),c=/^[A-Za-z_$:][\w$:]*$/;function assertSafeKey(e){if(f.has(e))throw o(`Forbidden attribute name '${e}'`)}function parsePath(e){if("string"!=typeof e||0===e.length||e.length>512)throw o(`Malformed path '${String(e)}'`);let t,r,n;const i=e.indexOf("[");if(i>=0){const a=e.lastIndexOf("]");if(a<=i)throw o(`Malformed path '${e}'`);t=e.slice(0,i),r=e.slice(i+1,a);const s=e.slice(a+1);if(s.length>0){if("."!==s[0])throw o(`Malformed path '${e}'`);n=s.slice(1)}}else{const r=e.indexOf(".");r>=0?(t=e.slice(0,r),n=e.slice(r+1)):t=e}if(!c.test(t))throw o(`Malformed path '${e}'`);if(assertSafeKey(t),null!=n){if(!c.test(n))throw o(`Malformed sub-attribute in '${e}'`);assertSafeKey(n)}return{top:t,filter:r,sub:n}}async function applyOperation(e,r,f){const c=normalizeOp(e.op),p=f.memberAttribute||"member";if(!e.path){if("remove"===c)throw n("PATCH 'remove' requires a path");if(null==e.value||"object"!=typeof e.value||Array.isArray(e.value))throw n("PATCH without path requires an object value");const t=e.value;for(const[o,n]of Object.entries(t))await applyOperation({op:e.op,path:o,value:n},r,f);return}const{top:h,sub:d,filter:y}=parsePath(e.path);if("members"===h){const t=f.resolveMemberRef;if(!t)throw n("Member operations require a member resolver");const asMemberValues=async e=>{const r=Array.isArray(e)?e:[e],o=[];for(const e of r){const r="string"==typeof e?e:"object"==typeof e&&null!=e&&"value"in e?String(e.value):"";if(!r)continue;const n=await t(r);n&&o.push(n)}return o};if("add"===c)return void r.append(p,await asMemberValues(e.value));if("replace"===c){const t=await asMemberValues(e.value);if(0===t.length)return;return void r.set(p,t)}if(!y&&null==e.value)return void r.drop(p);const o=y?/value\s+eq\s+"([^"]+)"/i.exec(y)?.[1]??"":"",i=[...o?await asMemberValues(o):[],...null!=e.value?await asMemberValues(e.value):[]];return void r.drop(p,i)}if("active"===h&&f.supportsActive){if(d||y)throw o(`'active' has no sub-attribute (got '${e.path}')`);const n=f.lockAttribute||i,l=Object.keys(f.current).find(e=>e.toLowerCase()===n.toLowerCase())??n,p="remove"===c||function(e){const r=u(e);if(void 0===r)throw t("PATCH on 'active' requires a value");return r}(e.value),h=f.lockValue||a;return void(p?s({[l]:r.get(l)},l,h)&&r.drop(l):r.set(l,[h]))}if(y)throw o(`Complex multi-valued filters are only supported on 'members' (got '${e.path}')`);const v=l(d?`${h}.${d}`:h,f.mapping);if(!v)throw o(`Unknown SCIM attribute path '${e.path}'`);if("remove"===c)return void r.drop(v);const w=function(e){if(null!=e)return"string"==typeof e?e:"number"==typeof e||"boolean"==typeof e?String(e):Array.isArray(e)?e.map(e=>{if("string"==typeof e)return e;if("number"==typeof e||"boolean"==typeof e)return String(e);if(e&&"object"==typeof e&&!Array.isArray(e)&&"value"in e&&("string"==typeof e.value||"number"==typeof e.value||"boolean"==typeof e.value))return String(e.value);throw t(`Unsupported array element in PATCH value: ${JSON.stringify(e)}`)}):void 0}(e.value);if(null==w)throw t(`PATCH ${e.op} ${e.path} missing value`);const g=Array.isArray(w)?w:[w];"add"===c?r.append(v,g):r.set(v,g)}async function patchToModifyRequest(e,r){if(!e.Operations||!Array.isArray(e.Operations))throw t("Missing Operations array");const o=new WorkingEntry(r.current);for(const t of e.Operations)await applyOperation(t,o,r);return o.diff()}async function writePatch(t,o){for(let n=1;;n++)try{return await o()}catch(o){if(16!==e(o))throw o;if(2===n)throw new r(409,`${t} changed while this PATCH was being applied; nothing was written. Retry the request.`)}}function applyPatchToResource(e,t){const r={...e};for(const e of t.Operations){const t=normalizeOp(e.op);if(!e.path){if(e.value&&"object"==typeof e.value&&!Array.isArray(e.value))for(const[t,o]of Object.entries(e.value))assertSafeKey(t),r[t]=o;continue}const{top:o,sub:n}=parsePath(e.path);if("remove"!==t)if(n){const t=r[o],i=t&&"object"==typeof t&&!Array.isArray(t)?{...t}:Object.create(null);i[n]=e.value,r[o]=i}else r[o]=e.value;else if(n){const e=r[o];e&&"object"==typeof e&&delete e[n]}else delete r[o]}return r}export{applyPatchToResource,patchToModifyRequest,writePatch};
@@ -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 n,loadMappingFile as h,DEFAULT_USER_MAPPING as u,requiredLdapAttributes as l,ldapToScimUser as d,scimUserToLdap as p}from"./mapping.js";import{scimFilterToLdap as m}from"./filter.js";import{pagedSearch as f}from"./list.js";import{writePatch as b,patchToModifyRequest as g}from"./patch.js";import{scimNotFound as w,ScimError as k,scimInvalidValue as A,scimUniqueness as y}from"./errors.js";import{extractLdapCode as R}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=n(u,s?h(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 l(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===R(t))throw w(`User ${e} not found`);throw t}if(!i.searchEntries||0===i.searchEntries.length)throw w(`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=m(e.filter,this.mapping,{lockAttribute:this.lockAttribute,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 k&&404===t.statusCode)return{schemas:[o],totalResults:0,startIndex:1,itemsPerPage:0,Resources:[]};throw t}const{entries:n,totalResults:h}=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 u=n.map(e=>d(e,this.mapping,this.ctx(t)));return{schemas:[o],totalResults:h,startIndex:i,itemsPerPage:u.length,Resources:u}}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:n}=p(o,this.mapping,this.ctx(r),this.objectClass);if(!c)throw A("userName is required");s(c,this.rdnAttribute),n[this.rdnAttribute]=c;const h=this.baseResolver.userBase(r),u=`${this.rdnAttribute}=${t(c)},${h}`;try{await this.ldap.forRequest(r).add(u,n)}catch(t){if(68===R(t))throw y(`User ${c} already exists`);if(void 0!==n[this.lockAttribute])throw this.lockSchemaError(t);throw t}const l=await this.get(r,c);return i(this.hooks.scimusercreatedone,l),l}async replace(t,s,r){const a=this.dnForId(s,t),o=await this.ldap.forRequest(t).search({paged:!1,scope:"base",attributes:this.ldapAttributes()},a);if(!o.searchEntries?.length)throw w(`User ${s} not found`);const c=o.searchEntries[0],n=(await e(this.hooks.scimuserupdate,[s,r,t]))[1],{attributes:h,active:u}=p(n,this.mapping,this.ctx(t),this.objectClass),l={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!=h[t]?l.replace[t]=h[t]:hasAttrValue(c[t])&&l.delete.push(t));if(!1===u?l.replace[this.lockAttribute]=this.lockValue:!0===u&&hasAttrValue(c[this.lockAttribute])&&l.delete.push(this.lockAttribute),0===Object.keys(l.replace||{}).length&&delete l.replace,0===l.delete.length&&delete l.delete,l.replace||l.delete)try{await this.ldap.forRequest(t).modify(a,l)}catch(t){if(void 0!==u)throw this.lockSchemaError(t);if(16!==R(t))throw t;throw new k(409,`${a} changed while this PUT was being applied; nothing was written. Retry the request.`)}const m=await this.get(t,s);return i(this.hooks.scimuserupdatedone,s,m),m}async patch(t,e,s){const r=this.dnForId(e,t);await b(r,async()=>{const i=await this.currentEntry(t,e),a=await g(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){if(t.replace&&e in t.replace)return!0;if(t.add&&e in t.add)return!0;const s=t.delete;return Array.isArray(s)?s.includes(e):!(!s||"object"!=typeof s)&&e in 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=R(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===R(t))throw w(`User ${e} not found`);throw t}if(!i.searchEntries?.length)throw w(`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);if(s.includes("=")&&s.includes(","))return r(s,i)?s:void 0;const o=`${this.rdnAttribute}=${t(s)},${i}`;try{const t=await this.ldap.forRequest(e).search({paged:!1,scope:"base",attributes:["dn"]},o);if(t.searchEntries&&t.searchEntries.length>0)return t.searchEntries[0].dn}catch(t){if(32!==R(t))throw t}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!==R(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 k}from"./patch.js";import{scimNotFound as w,ScimError as y,scimInvalidValue as A,scimUniqueness as R}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 w(`User ${e} not found`);throw t}if(!i.searchEntries||0===i.searchEntries.length)throw w(`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");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),o=await this.ldap.forRequest(t).search({paged:!1,scope:"base",attributes:this.ldapAttributes()},a);if(!o.searchEntries?.length)throw w(`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 k(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 w(`User ${e} not found`);throw t}if(!i.searchEntries?.length)throw w(`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);if(s.includes("=")&&s.includes(","))return r(s,i)?s:void 0;const o=`${this.rdnAttribute}=${t(s)},${i}`;try{const t=await this.ldap.forRequest(e).search({paged:!1,scope:"base",attributes:["dn"]},o);if(t.searchEntries&&t.searchEntries.length>0)return t.searchEntries[0].dn}catch(t){if(32!==x(t))throw t}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};
@@ -0,0 +1 @@
1
+ import{randomUUID as t}from"node:crypto";import{BadRequestError as e,HttpError as s}from"../../lib/errors.js";import{extractLdapCode as r}from"../../lib/ldapCodes.js";import{parseDn as o,unescapeDnValue as i,escapeDnValue as a,isDummyMemberDn as n,escapeLdapFilter as c,normalizeDn as u}from"../../lib/utils.js";import p from"../ldap/groups.js";import{BaseResolver as h}from"../scim/baseResolver.js";import{mergeMapping as d,loadMappingFile as m,DEFAULT_GROUP_MAPPING as g}from"../scim/mapping.js";import{valueOf as l,lifecycleAttributes as f,first as w}from"./lifecycleAttributes.js";const read=(t,e)=>w(l(t,e)),y="{org}",b=["displayName","description","createdAt"],_=["uid","displayName","mail","jobTitle"],O={uid:"uid",_id:"entryUUID",cn:"cn",sn:"sn",givenName:"givenName",displayName:"displayName",mail:"mail",mobile:"mobile",jobTitle:"twakeJobTitle",company:"twakeCompany",organizationRole:"twakeOrganizationRole",organizationId:"twakeOrganizationId"},$={familyName:"sn",givenName:"givenName",additionalName:"twakeAdditionalName",namePrefix:"twakeNamePrefix"},v="twakeIsTechnical",I={organization:"Organization not found",group:"Group not found",user:"User not found",member:"Member not found"};class RouteError extends Error{constructor(t,e,s){super(e),this.status=t,this.code=s}}const notFound=t=>new RouteError(404,I[t],`${t.toUpperCase()}_NOT_FOUND`),invalid=(t,e="INVALID_INPUT")=>new RouteError(400,t,e),N="name must be a non-blank string of at most 256 characters with no control characters";function isDisplayName(t){return"string"==typeof t&&t.trim().length>0&&t.length<=256&&!/[\x00-\x1f\x7f]/.test(t)}const R=/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/,isColor=t=>"string"==typeof t&&R.test(t),text=t=>"string"==typeof t||"number"==typeof t?String(t):"";function bodyOf(t){const e=t.body;if(!e||"object"!=typeof e||Array.isArray(e))throw invalid("Request body must be a JSON object");return e}function values(t){return null==t?[]:(Array.isArray(t)?t:[t]).map(t=>t.toString())}function dnKey(t){try{return u(t)}catch{return t.toLowerCase()}}function parentOf(t){return o(t).slice(1).join(",")}class TwakeGroups extends p{constructor(e){super(e),this.name="twakeGroups",this.hooks={ldapaddrequest:([t,e,s])=>(this.checkMembers(t,e.member),void 0!==this.organizationOf(t)&&void 0!==l(e,this.displayName)&&void 0===l(e,this.createdAt)&&(e={...e,[this.createdAt]:(new Date).toISOString()}),[t,e,s]),ldapmodifyrequest:([t,e,s,r])=>(this.checkMembers(t,e.add?.member),this.checkMembers(t,e.replace?.member),[t,e,s,r]),ldapsearchfilter:async([t,e,s])=>[await this.hideTombstones(t),e,s],scimgroupcreate:([e,s,r])=>(r??(r=new h(this.config).groupBase(s)),[this.groupPattern.test(o(r).join(","))?{...e,id:t()}:e,s,r])},this.groupBase=this.config.twake_group_base||"",this.userBase=this.config.twake_group_user_base||"";for(const[t,e]of[["--twake-group-base",this.groupBase],["--twake-group-user-base",this.userBase]])if(!e.includes(y))throw new Error(`${this.name}: ${t} must hold ${y}`);const[s,r]=o(this.groupBase).join(",").split(y).map(t=>t.replace(/[.*+?^${}()|[\]\\]/g,"\\$&"));this.groupPattern=new RegExp(`^${s}(?<org>(?:\\\\.|[^,])+)${r}$`,"i"),this.attrs=f(this.config),this.orgPattern=this.config.twake_group_organization_dn||"",this.displayName=this.config.twake_group_display_name_attribute||"twakeDisplayName",this.color=this.config.twake_group_color_attribute||"twakeGroupColor",this.createdAt=this.config.twake_group_created_at_attribute||"twakeCreatedAt",this.maxPage=this.config.twake_group_max_page_limit||1e3,this.memberFields={...O,...this.config.twake_group_member_fields}}adaptSchema(t){const e={[this.displayName]:{type:"string",required:!0},[this.color]:{type:"string",test:R.source},[this.createdAt]:{type:"string"}};for(const[s,r]of Object.entries(e)){const e=Object.keys(t.attributes).find(t=>t.toLowerCase()===s.toLowerCase()),o=e?t.attributes[e]:r;e&&delete t.attributes[e],t.attributes[s]=o}return t}afterLoad(){if(!this.server.loadedPlugins.scim)return;const t=this.config.scim_group_rdn_attribute||"cn",e=this.config.scim_group_mapping||"",s=d(g,e?m(e):void 0).entries.find(e=>e.ldap?.toLowerCase()===t.toLowerCase());s&&this.logger.warn(`${this.name}: the SCIM group mapping writes ${s.scim} to ${t}, so SCIM groups keep it as their RDN instead of a generated cn: map ${s.scim} to another attribute with --scim-group-mapping`)}organizationOf(t){const e=o(t).join(","),s=this.groupPattern.exec(parentOf(e));return s?.groups?i(s.groups.org):void 0}groupBaseOf(t){return this.groupBase.replace(y,a(t))}userBaseOf(t){return this.userBase.replace(y,a(t))}checkMembers(t,s){const r=this.organizationOf(t);if(!r)return;const o=dnKey(this.userBaseOf(r));for(const t of values(s))if(!n(t,this.config.group_dummy_user)&&dnKey(parentOf(t))!==o)throw new e(`${t} is not a user of organization ${r}`)}async hideTombstones(t){if(!this.attrs.deleted)return t;const e=new Set;for(const s of t.searchEntries){const t=this.organizationOf(s.dn);t&&void 0!==s.member&&e.add(t)}if(0===e.size)return t;const s=new Set;for(const t of e)for(const e of await this.tombstonesOf(t))s.add(dnKey(e));return 0===s.size?t:{...t,searchEntries:t.searchEntries.map(t=>void 0!==t.member&&this.organizationOf(t.dn)?{...t,member:values(t.member).filter(t=>!s.has(dnKey(t)))}:t)}}async tombstonesOf(t){const{deleted:e,deletedValue:s}=this.attrs;try{const{searchEntries:r}=await this.ldap.search({paged:!1,scope:"one",filter:`(${e}=${c(s)})`,attributes:["dn"]},this.userBaseOf(t));return r.map(t=>t.dn)}catch(t){if(32===r(t))return[];throw t}}api(t){const e=`${this.config.api_prefix}/v1/organizations/:id/groups`,route=(t,e)=>async(r,o)=>{try{const t=r.params.id;await this.checkOrganization(t),await e(r,o,t)}catch(e){if(e instanceof RouteError)return void o.status(e.status).json({error:e.message,code:e.code});if(e instanceof s&&e.statusCode<500)return void o.status(e.statusCode).json({error:e.message,code:400===e.statusCode?"INVALID_INPUT":"REFUSED"});this.logger.error({plugin:this.name,context:t,error:String(e)}),o.status(500).json({error:`Error ${t}`,code:"INTERNAL_ERROR"})}};t.get(e,route("listing groups",(t,e,s)=>this.listRoute(t,e,s))),t.post(e,route("creating group",(t,e,s)=>this.createRoute(t,e,s))),t.get(`${e}/:groupId`,route("getting group",async(t,e,s)=>{const r=await this.readGroup(s,t.params.groupId);if(!r)throw notFound("group");e.json(r)})),t.get(`${e}/:groupId/members`,route("listing group members",(t,e,s)=>this.membersRoute(t,e,s))),t.patch(`${e}/:groupId`,route("updating group",(t,e,s)=>this.updateRoute(t,e,s))),t.delete(`${e}/:groupId`,route("deleting group",async(t,e,s)=>{await this.onGroup(()=>this.deleteGroup(this.groupDn(s,t.params.groupId),t)),e.json({success:!0})})),t.post(`${e}/:groupId/members`,route("adding group members",(t,e,s)=>this.addMembersRoute(t,e,s))),t.delete(`${e}/:groupId/members/:userId`,route("removing group member",(t,e,s)=>this.removeMemberRoute(t,e,s)))}groupDn(t,e){return`${this.cn}=${a(e)},${this.groupBaseOf(t)}`}get userAttribute(){return this.config.ldap_user_main_attribute||"uid"}async checkOrganization(t){if(!this.orgPattern)return;const e=this.config.twake_group_organization_status_attribute||"twakeOrgStatus";let s;try{s=(await this.ldap.search({paged:!1,scope:"base",attributes:[e]},this.orgPattern.replace(y,a(t)))).searchEntries[0]}catch(t){if(32!==r(t))throw t}if(!s)throw notFound("organization");const o=this.config.twake_group_organization_deleted_value||"deleted";if(read(s,e)===o)throw new RouteError(410,"Organization has been deleted","ORGANIZATION_DELETED")}page(t,e){const s=Math.max(1,parseInt(t.query.page)||1),r=Math.max(1,Math.min(parseInt(t.query.limit)||20,this.maxPage)),o=t.query.search;if(void 0!==o&&("string"!=typeof o||o.length<2))throw invalid("Search query must be at least 2 characters","INVALID_SEARCH_QUERY");const i=t.query.sortBy;if(void 0!==i&&!e.includes(i))throw invalid(`sortBy must be one of: ${e.join(", ")}`,"INVALID_SORT_FIELD");return{page:s,limit:r,offset:(s-1)*r,search:o,sortBy:i,desc:"desc"===t.query.sortOrder}}sorted(t,e,s){if(!e)return t;const r=s?-1:1;return t.sort((t,s)=>text(t[e]).localeCompare(text(s[e]))*r)}groupOf(t,e){const s=read(e,this.cn)||"",r=dnKey(this.userBaseOf(t)),a=values(e.member).filter(t=>!n(t,this.config.group_dummy_user)).map(t=>{const[e]=o(t),s=e.indexOf("=");return dnKey(parentOf(t))===r&&s>0?i(e.slice(s+1).trim()):t});return{id:s,cn:s,displayName:read(e,this.displayName)??s,description:read(e,"description"),color:read(e,this.color),organizationId:t,baseDN:this.groupDn(t,s),members:a,createdAt:read(e,this.createdAt)}}async groups(t,e){try{const{searchEntries:s}=await this.ldap.search({paged:!1,scope:"one",filter:e},this.groupBaseOf(t));return s}catch(t){if(32===r(t))return[];throw t}}async readGroup(t,e){try{const{searchEntries:s}=await this.ldap.search({paged:!1,scope:"base"},this.groupDn(t,e));return s[0]&&this.groupOf(t,s[0])}catch(t){if(32===r(t))return;throw t}}async named(t,e){const[s]=await this.groups(t,`(${this.displayName}=${c(e)})`);return s&&read(s,this.cn)}async onGroup(t){try{return await t()}catch(t){if(32===r(t))throw notFound("group");throw t}}async listRoute(t,e,s){const r=this.page(t,b);let o=`(${this.cn}=*)`;if(r.search){const t=c(r.search);o=`(&${o}(|(${this.displayName}=*${t}*)(description=*${t}*)))`}const i=this.sorted((await this.groups(s,o)).map(t=>this.groupOf(s,t)),r.sortBy,r.desc);e.json({organizationId:s,groups:i.slice(r.offset,r.offset+r.limit),pagination:{page:r.page,limit:r.limit,total:i.length,totalPages:Math.ceil(i.length/r.limit)}})}async createRoute(e,s,r){const o=bodyOf(e);if(!isDisplayName(o.name))throw invalid(N);if(void 0!==o.description&&"string"!=typeof o.description)throw invalid("description must be a string");if(void 0!==o.color&&!isColor(o.color))throw invalid("color must be a hex code (e.g. #RRGGBB or #RGB)");if(await this.named(r,o.name))throw new RouteError(409,"Group already exists","GROUP_EXISTS");const i=t(),a={objectClass:this.config.group_class||["top","groupOfNames"],[this.displayName]:o.name,[this.createdAt]:(new Date).toISOString()};o.description&&(a.description=o.description),o.color&&(a[this.color]=o.color),await this.addGroup(this.groupDn(r,i),[],a,e);const n=await this.readGroup(r,i);s.status(201).json(n)}async updateRoute(t,e,s){const r=bodyOf(t),o=t.params.groupId,i=Object.keys(r).filter(t=>!["name","description","color"].includes(t));if(i.length)throw invalid(`Only "name", "description" and "color" can be updated; unsupported field(s): ${i.join(", ")}`);const{name:a,description:n,color:c}=r;if(void 0===a&&void 0===n&&void 0===c)throw invalid('at least one of "name", "description" or "color" must be provided');if(void 0!==a&&!isDisplayName(a))throw invalid(N);if(void 0!==n&&"string"!=typeof n)throw invalid("description must be a string");if(void 0!==c&&""!==c&&!isColor(c))throw invalid("color must be a hex code (e.g. #RRGGBB or #RGB) or empty to clear it");const u={};if(void 0!==a){const t=await this.named(s,a);if(t&&t!==o)throw new RouteError(409,"Group already exists","GROUP_EXISTS");u[this.displayName]=a}void 0!==n&&(u.description=n||[]),void 0!==c&&(u[this.color]=c||[]),await this.onGroup(()=>this.modifyGroup(this.groupDn(s,o),{replace:u},t)),e.json({success:!0})}async membersRoute(t,e,s){const r=this.page(t,_),o=t.params.groupId,i=await this.readGroup(s,o);if(!i)throw notFound("group");const a=[...new Map(i.members.map(t=>[t.toLowerCase(),t])).values()],n=await this.profiles(s,a);let c=a.map(t=>n.get(t.toLowerCase())??{uid:t});if(r.search){const t=r.search.toLowerCase();c=c.filter(e=>[e.uid,e.displayName||e.cn,e.mail].some(e=>text(e).toLowerCase().includes(t)))}c=this.sorted(c,r.sortBy??"uid",r.desc);const u=Math.ceil(c.length/r.limit);e.json({organizationId:s,id:o,members:c.slice(r.offset,r.offset+r.limit),pagination:{page:r.page,limit:r.limit,total:c.length,totalPages:u,hasNextPage:r.page<u,hasPreviousPage:r.page>1}})}async users(t,e,s){const{deleted:o,deletedValue:i}=this.attrs,a=o?`(!(${o}=${c(i)}))`:"",n=[];for(let o=0;o<e.length;o+=this.maxPage){const i=`(&(|${e.slice(o,o+this.maxPage).map(t=>`(${this.userAttribute}=${c(t)})`).join("")})${a})`;try{const{searchEntries:e}=await this.ldap.search({paged:!1,scope:"one",filter:i,attributes:s},this.userBaseOf(t));n.push(...e)}catch(t){if(32!==r(t))throw t}}return n}async profiles(t,e){const s=[...new Set([...Object.values(this.memberFields),...Object.values($),v])],r=new Map;for(const o of await this.users(t,e,s)){const t=read(o,this.userAttribute);if(!t)continue;const e={};for(const[t,s]of Object.entries(this.memberFields)){const r=read(o,s);void 0!==r&&(e[t]=r)}const s={};for(const[t,e]of Object.entries($)){const r=read(o,e);void 0!==r&&(s[t]=r)}Object.keys(s).length&&(e.name=s),e.isTechnical="TRUE"===read(o,v)?.toUpperCase(),r.set(t.toLowerCase(),e)}return r}async addMembersRoute(t,e,s){const{usernames:r}=bodyOf(t);if(!Array.isArray(r)||0===r.length)throw invalid("usernames must be a non-empty array");if(!r.every(t=>"string"==typeof t&&t.length>0))throw invalid("usernames must be an array of non-empty strings");if(r.length>this.maxPage)throw invalid(`usernames cannot exceed ${this.maxPage} entries per request`);const o=t.params.groupId,i=await this.readGroup(s,o);if(!i)throw notFound("group");const n=[...new Map(r.map(t=>[t.toLowerCase(),t])).keys()],c=new Map((await this.users(s,n,[this.userAttribute])).map(t=>{const e=read(t,this.userAttribute)||"";return[e.toLowerCase(),e]}));if(n.some(t=>!c.has(t)))throw notFound("user");const u=new Set(i.members.map(t=>t.toLowerCase())),p=n.filter(t=>!u.has(t)).map(t=>`${this.userAttribute}=${a(c.get(t))},${this.userBaseOf(s)}`),h=this.groupDn(s,o);p.length&&(await this.onGroup(()=>this.ldap.modify(h,{add:{member:p}},t)),await this.dropPlaceholder(h,t)),e.json({success:!0})}async dropPlaceholder(t,e){const s=this.config.group_dummy_user;if(s)try{await this.ldap.modify(t,{delete:{member:s}},e)}catch(t){if(![16,65].includes(r(t)??0))throw t}}async removeMemberRoute(t,e,s){const o=this.groupDn(s,t.params.groupId),i=`${this.userAttribute}=${a(t.params.userId)},${this.userBaseOf(s)}`;try{await this.ldap.modify(o,{delete:{member:i}},t)}catch(e){const s=r(e);if(32===s)throw notFound("group");if(16===s)throw notFound("member");if(65!==s||!this.config.group_dummy_user)throw e;await this.ldap.modify(o,{replace:{member:[this.config.group_dummy_user]}},t)}e.json({success:!0})}}export{TwakeGroups as default,values};
@@ -1 +1 @@
1
- import t from"../../abstract/plugin.js";import{NotFoundError as e,BadRequestError as i}from"../../lib/errors.js";import{jsonBody as a}from"../../lib/expressFormatedResponses.js";import{CozyStackProvider as n,ClouderyProvider as r}from"../../lib/instanceProviders.js";import{extractLdapCode as s}from"../../lib/ldapCodes.js";import{rdnValue as o,asyncHandler as c,escapeLdapFilter as d}from"../../lib/utils.js";import{lifecycleAttributes as h,isTombstone as u}from"./lifecycleAttributes.js";const first=t=>{const e=Array.isArray(t)?t[0]:t;return(null==e?"":e.toString())||void 0},l=/(^|\/)twake\/(cozy|cloudery)Provision(\.js)?$/;class TwakeInstances extends t{constructor(t){super(t),this.name="twakeInstances",this.roles=["consistency","api"],this.dependencies={rabbitmq:"core/rabbitmq"},this.queues=new Map,this.hooks={ldapaddrequest:([t,e,i])=>{if(this.patterns.some(e=>e.test(t))||this.isOrganization(t)){const t=[this.fqdnAttribute,this.sentAttribute,this.organizationFqdn].map(t=>t.toLowerCase());for(const i of Object.keys(e))t.includes(i.toLowerCase())&&delete e[i]}return[t,e,i]},ldapaddafter:async([t,e])=>{const i=this.account(t,e);i&&await this.serialized(t,async()=>{try{await this.provide(t,e,i)}catch(e){this.logger.error({plugin:this.name,event:"create",dn:t,error:String(e)})}})},ldapdeletedone:async t=>{const e=this.provider;if(e instanceof n)for(const i of Array.isArray(t)?t:[t]){const t=this.match(i),a=/^uid=/i.test(i)?o(i):"";t&&a&&await this.serialized(i,async()=>{try{await e.destroy(this.id(a,t),a)}catch(t){this.logger.error({plugin:this.name,event:"destroy",dn:i,error:String(t)})}})}}};const e=this.config,i=(e.plugin||[]).find(t=>l.test(t));if(i)throw new Error(`${this.name}: ${i} gives accounts their instance too; load one of them`);this.patterns=(e.twake_instance_dn||[]).map(t=>new RegExp(t,"i"));const a=new Set(["uid"]);for(const t of this.patterns)for(const[,e]of t.source.matchAll(/\(\?<(\w+)>/g))a.add(e);for(const[,t]of(e.twake_instance_id||"{uid}").matchAll(/\{(\w+)\}/g))if(!a.has(t))throw new Error(`${this.name}: --twake-instance-id names {${t}}, which no --twake-instance-dn group captures`);this.lifecycle=h(e),this.fqdnAttribute=e.twake_instance_fqdn_attribute||"twakeWorkspaceUrl",this.sentAttribute=e.twake_instance_sent_attribute||"twakeCreatedEventAt";const s=e.twake_instance_timeout||3e4;if("cozy-stack"===e.twake_instance_provider){if(!e.twake_instance_cozy_url||!e.twake_instance_cozy_domain)throw new Error(`${this.name}: --twake-instance-cozy-url and --twake-instance-cozy-domain are required`);this.provider=new n(e.twake_instance_cozy_url.replace(/\/$/,""),e.twake_instance_cozy_user||"admin",e.twake_instance_cozy_passphrase||"",e.twake_instance_cozy_domain,e.twake_instance_cozy_context||"",e.twake_instance_cozy_apps||"",s),this.locale=e.twake_instance_locale||"fr"}else{if("cloudery"!==e.twake_instance_provider)throw new Error(`${this.name}: --twake-instance-provider must be cloudery or cozy-stack`);if(!e.twake_instance_cloudery_url||!e.twake_instance_cloudery_domain)throw new Error(`${this.name}: --twake-instance-cloudery-url and --twake-instance-cloudery-domain are required`);this.provider=new r(e.twake_instance_cloudery_url.replace(/\/$/,""),e.twake_instance_cloudery_token||"",e.twake_instance_cloudery_domain,e.twake_instance_cloudery_offer||"",s),this.locale=e.twake_instance_locale||"en"}}async api(t){const n=this.requirePlugin("rabbitmq");if(!n||!await n.getRawClient())throw new Error(`${this.name}: RabbitMQ is required and unreachable`);const s=await this.server.ldap.schemaIndex(),o=[this.fqdnAttribute,this.sentAttribute,...this.config.twake_instance_organization_base?[this.organizationDomain,this.organizationFqdn]:[]].filter(t=>t&&!s.getAttributeType(t));if(o.length)throw new Error(`${this.name}: the directory schema defines no ${o.join(", ")}; load a schema that does, or name other attributes`);t.post(`${this.config.api_prefix}/v1/twake/instances/ensure`,c(async(t,n)=>{const r=a(t,n);if(!1===r)return;let s=r?.dn;if(void 0===s&&"string"==typeof r?.mail&&r.mail){const i=await this.findByMail(r.mail,t);if(!i)throw new e(`No account for ${r.mail}`);s=i.dn}if("string"!=typeof s||!s)throw new i("Give the account as dn or mail");n.json({state:await this.ensureInstance(s,t)})})),this.provider instanceof r&&await n.subscribe(this.config.twake_instance_auth_exchange||"auth",this.config.twake_instance_workplace_created_key||"workplace.created",this.config.twake_instance_queue||"workplace.created.ldap-rest",t=>this.onWorkplaceCreated(t))}async ensureInstance(t,a){return this.serialized(t,async()=>{const n=await this.read(t,a);if(!n)throw new e(`No entry at ${t}`);const r=this.account(t,n);if(!r)throw new i(`${t} gets no instance`);if(first(this.value(n,this.sentAttribute)))return"ready";a&&await this.server.ldap.modify(t,{replace:{[this.sentAttribute]:[]}},a);const s=first(this.value(n,this.fqdnAttribute));if(s){const e=s.toLowerCase()===this.provider.address(r)?{fqdn:s,ready:!0}:await this.provider.find(r,s);if(e?.ready)return await this.ready(t,n,r,e.fqdn,a),"ready"}return this.provide(t,n,r,a)})}async ensureOrganization(t){return this.serialized(`organization:${t.id}`,async()=>{const i=await this.findOrganization(t.id);if(!i)throw new e(`No organization ${t.id}`);if(first(this.value(i,this.sentAttribute)))return"ready";const a=this.organizationFqdn&&first(this.value(i,this.organizationFqdn)),n={id:t.id,email:this.organizationMail(t.id,t.domain),publicName:t.name,locale:this.locale,orgId:t.id,orgDomain:t.domain,offer:this.config.twake_instance_cloudery_organization_offer},s=a?{fqdn:a,ready:!0}:await this.provider.find(n);let o=s?.ready&&s.fqdn;return s||(this.provider instanceof r&&await this.provider.createOrganization(t),o=await this.provider.create(n)),o?(await this.organizationReady(i,o),"ready"):"pending"})}async onWorkplaceCreated(t){const{internalEmail:e,workplaceFqdn:i}=t;if(!e||!i)return;const a=t.twakeId&&await this.findOrganization(t.twakeId);if(a){const n=t.twakeId.toLowerCase(),r=first(this.value(a,this.organizationDomain)),s=e.toLowerCase();if(r?this.organizationMail(n,r)===s:s.startsWith(`${n}@`))return this.serialized(`organization:${t.twakeId}`,async()=>{const t=await this.read(a.dn);t&&await this.organizationReady(t,i)})}const n=await this.findByMail(e);if(!n)return void this.logger.debug(`${this.name}: no account here for ${e}`);const r=n.dn;await this.serialized(r,async()=>{const t=await this.read(r),e=t&&this.account(r,t);t&&e&&(first(this.value(t,this.sentAttribute))||await this.ready(r,t,e,i))})}async provide(t,e,i,a){const n=await this.provider.find(i),r=n?n.ready&&n.fqdn:await this.provider.create(i);return r?(await this.ready(t,e,i,r,a),"ready"):"pending"}async ready(t,e,i,a,n){first(this.value(e,this.fqdnAttribute))!==a&&await this.server.ldap.modify(t,{replace:{[this.fqdnAttribute]:a}},n),first(this.value(e,this.sentAttribute))||(await this.announce(this.config.twake_instance_auth_exchange||"auth",this.config.twake_instance_user_created_key||"user.created",{twakeId:i.twakeId,internalEmail:i.email,workplaceFqdn:a,...i.orgId?{organizationId:i.orgId}:{},...i.orgDomain?{domain:i.orgDomain,organizationDomain:i.orgDomain}:{},...i.phone?{mobile:i.phone}:{}}),await this.server.ldap.modify(t,{replace:{[this.sentAttribute]:(new Date).toISOString()}},n))}async organizationReady(t,e){const i=t.dn;if(first(this.value(t,this.sentAttribute)))return;this.organizationFqdn&&!first(this.value(t,this.organizationFqdn))&&await this.server.ldap.modify(i,{replace:{[this.organizationFqdn]:e}});const a=first(this.value(t,this.organizationId)),n=first(this.value(t,this.organizationDomain));await this.announce(this.config.twake_instance_b2b_exchange||"b2b",this.config.twake_instance_organization_created_key||"organization.created",{organizationId:a,workplaceFqdn:e,...n?{domain:n}:{}}),await this.server.ldap.modify(i,{replace:{[this.sentAttribute]:(new Date).toISOString()}})}async serialized(t,e){const i=t.toLowerCase(),a=(this.queues.get(i)??Promise.resolve()).catch(()=>{}).then(e),n=a.catch(()=>{});return this.queues.set(i,n),n.then(()=>{this.queues.get(i)===n&&this.queues.delete(i)}),a}get organizationId(){return this.config.twake_instance_organization_id_attribute||"ou"}get organizationDomain(){return this.config.twake_instance_organization_domain_attribute||"twakeDomain"}get organizationFqdn(){return this.config.twake_instance_organization_fqdn_attribute||""}organizationMail(t,e){return`${t}@${e}`.toLowerCase()}isOrganization(t){const e=this.config.twake_instance_organization_base;if(!e)return!1;return t.slice(t.indexOf(",")+1).toLowerCase()===e.toLowerCase()}async findOrganization(t){const e=this.config.twake_instance_organization_base;if(!e)return;return(await this.server.ldap.search({paged:!1,scope:"one",filter:`(${this.organizationId}=${d(t)})`},e)).searchEntries[0]}async read(t,e){try{return(await this.server.ldap.search({paged:!1,scope:"base"},t,e)).searchEntries[0]}catch(e){const a=s(e);if(32===a)return;if(34===a)throw new i(`Invalid DN: ${t}`);throw e}}async announce(t,e,i){const a=this.requirePlugin("rabbitmq");if(!a||!await a.getRawClient())throw new Error(`${this.name}: RabbitMQ unreachable, ${e} not sent`);await a.publish(t,e,i),this.logger.info({plugin:this.name,event:e,exchange:t})}account(t,e){const i=this.match(t);if(!i)return;if(u(e,this.lifecycle))return;const a=this.config.twake_instance_skip_attribute;if(a&&first(this.value(e,a))?.toLowerCase()===(this.config.twake_instance_skip_value||"").toLowerCase())return;const r=first(this.value(e,"mail")),s=first(this.value(e,"uid"));if(!r||!s)return void this.logger.warn(`${this.name}: ${t} has no mail or uid, no instance`);const o=this.provider instanceof n,c=i.groups?.org||(o?this.config.twake_instance_cozy_org_id:"")||void 0,d=i.groups?.org?r.split("@")[1]:o&&this.config.twake_instance_cozy_org_domain||void 0;return{id:this.id(s,i),...o?{oidc:s}:{},twakeId:s,email:r,publicName:first(this.value(e,"displayName"))||first(this.value(e,"cn"))||s,locale:first(this.value(e,"preferredLanguage"))||this.locale,phone:first(this.value(e,"mobile")),...c?{orgId:c}:{},...d?{orgDomain:d}:{}}}match(t){return this.patterns.map(e=>e.exec(t)).find(t=>null!==t)}id(t,e){const i={uid:t,...e.groups};return(this.config.twake_instance_id||"{uid}").replace(/\{(\w+)\}/g,(t,e)=>i[e]??"").replace(/\./g,"").toLowerCase()}value(t,e){const i=e.toLowerCase(),a=Object.keys(t).find(t=>t.toLowerCase()===i);return a?t[a]:void 0}async findByMail(t,e){return(await this.server.ldap.search({paged:!1,scope:"sub",filter:`(mail=${d(t)})`},this.config.ldap_base,e)).searchEntries.find(t=>this.patterns.some(e=>e.test(t.dn)))}}export{TwakeInstances as default};
1
+ import t from"../../abstract/plugin.js";import{NotFoundError as i,BadRequestError as e}from"../../lib/errors.js";import{jsonBody as a}from"../../lib/expressFormatedResponses.js";import{CozyStackProvider as n,ClouderyProvider as r}from"../../lib/instanceProviders.js";import{extractLdapCode as s}from"../../lib/ldapCodes.js";import{rdnValue as o,asyncHandler as c,escapeLdapFilter as h}from"../../lib/utils.js";import{lifecycleAttributes as d,isTombstone as u}from"./lifecycleAttributes.js";const first=t=>{const i=Array.isArray(t)?t[0]:t;return(null==i?"":i.toString())||void 0},l=/(^|\/)twake\/(cozy|cloudery)Provision(\.js)?$/;class TwakeInstances extends t{constructor(t){super(t),this.name="twakeInstances",this.roles=["consistency","api"],this.dependencies={rabbitmq:"core/rabbitmq"},this.queues=new Map,this.hooks={ldapaddrequest:([t,i,e])=>{if(this.patterns.some(i=>i.test(t))||this.isOrganization(t)){const t=[this.fqdnAttribute,this.sentAttribute,this.organizationFqdn].map(t=>t.toLowerCase());for(const e of Object.keys(i))t.includes(e.toLowerCase())&&delete i[e]}return[t,i,e]},ldapaddafter:async([t,i])=>{const e=this.account(t,i);e&&await this.serialized(t,async()=>{try{await this.provide(t,i,e)}catch(i){this.logger.error({plugin:this.name,event:"create",dn:t,error:String(i)})}})},ldapdeletedone:async t=>{const i=this.provider;if(i instanceof n)for(const e of Array.isArray(t)?t:[t]){const t=this.match(e),a=/^uid=/i.test(e)?o(e):"";t&&a&&await this.serialized(e,async()=>{try{await i.destroy(this.id(a,t),a)}catch(t){this.logger.error({plugin:this.name,event:"destroy",dn:e,error:String(t)})}})}}};const i=this.config,e=(i.plugin||[]).find(t=>l.test(t));if(e)throw new Error(`${this.name}: ${e} gives accounts their instance too; load one of them`);this.patterns=(i.twake_instance_dn||[]).map(t=>new RegExp(t,"i"));const a=new Set(["uid"]);for(const t of this.patterns)for(const[,i]of t.source.matchAll(/\(\?<(\w+)>/g))a.add(i);for(const[,t]of(i.twake_instance_id||"{uid}").matchAll(/\{(\w+)\}/g))if(!a.has(t))throw new Error(`${this.name}: --twake-instance-id names {${t}}, which no --twake-instance-dn group captures`);this.lifecycle=d(i),this.fqdnAttribute=i.twake_instance_fqdn_attribute||"twakeWorkspaceUrl",this.sentAttribute=i.twake_instance_sent_attribute||"twakeCreatedEventAt";const s=i.twake_instance_timeout||3e4;if("cozy-stack"===i.twake_instance_provider){if(!i.twake_instance_cozy_url||!i.twake_instance_cozy_domain)throw new Error(`${this.name}: --twake-instance-cozy-url and --twake-instance-cozy-domain are required`);this.provider=new n(i.twake_instance_cozy_url.replace(/\/$/,""),i.twake_instance_cozy_user||"admin",i.twake_instance_cozy_passphrase||"",i.twake_instance_cozy_domain,i.twake_instance_cozy_context||"",i.twake_instance_cozy_apps||"",s),this.locale=i.twake_instance_locale||"fr"}else{if("cloudery"!==i.twake_instance_provider)throw new Error(`${this.name}: --twake-instance-provider must be cloudery or cozy-stack`);if(!i.twake_instance_cloudery_url||!i.twake_instance_cloudery_domain)throw new Error(`${this.name}: --twake-instance-cloudery-url and --twake-instance-cloudery-domain are required`);this.provider=new r(i.twake_instance_cloudery_url.replace(/\/$/,""),i.twake_instance_cloudery_token||"",i.twake_instance_cloudery_domain,i.twake_instance_cloudery_offer||"",s),this.locale=i.twake_instance_locale||"en"}}async api(t){const n=this.requirePlugin("rabbitmq");if(!n||!await n.getRawClient())throw new Error(`${this.name}: RabbitMQ is required and unreachable`);const s=await this.server.ldap.schemaIndex(),o=[this.fqdnAttribute,this.sentAttribute,...this.config.twake_instance_organization_base?[this.organizationDomain,this.organizationFqdn,this.organizationName]:[]].filter(t=>t&&!s.getAttributeType(t));if(o.length)throw new Error(`${this.name}: the directory schema defines no ${o.join(", ")}; load a schema that does, or name other attributes`);if(this.organizationFqdn&&this.organizationFqdn.toLowerCase()===this.organizationName.toLowerCase())throw new Error(`${this.name}: an organization's name and address cannot share the attribute ${this.organizationName}`);t.post(`${this.config.api_prefix}/v1/twake/instances/ensure`,c(async(t,n)=>{const r=a(t,n);if(!1===r)return;let s=r?.dn;if(void 0===s&&"string"==typeof r?.mail&&r.mail){const e=await this.findByMail(r.mail,t);if(!e)throw new i(`No account for ${r.mail}`);s=e.dn}if("string"!=typeof s||!s)throw new e("Give the account as dn or mail");n.json({state:await this.ensureInstance(s,t)})})),this.provider instanceof r&&await n.subscribe(this.config.twake_instance_auth_exchange||"auth",this.config.twake_instance_workplace_created_key||"workplace.created",this.config.twake_instance_queue||"workplace.created.ldap-rest",t=>this.onWorkplaceCreated(t))}async ensureInstance(t,a){return this.serialized(t,async()=>{const n=await this.read(t,a);if(!n)throw new i(`No entry at ${t}`);const r=this.account(t,n);if(!r)throw new e(`${t} gets no instance`);if(first(this.value(n,this.sentAttribute)))return"ready";a&&await this.server.ldap.modify(t,{replace:{[this.sentAttribute]:[]}},a);const s=first(this.value(n,this.fqdnAttribute));if(s){const i=s.toLowerCase()===this.provider.address(r)?{fqdn:s,ready:!0}:await this.provider.find(r,s);if(i?.ready)return await this.ready(t,n,r,i.fqdn,a),"ready"}return this.provide(t,n,r,a)})}async ensureOrganization(t){return this.serialized(`organization:${t.id}`,async()=>{const e=await this.findOrganization(t.id);if(!e)throw new i(`No organization ${t.id}`);t.name&&!first(this.value(e,this.organizationName))&&(await this.server.ldap.modify(e.dn,{replace:{[this.organizationName]:t.name}}),e[this.organizationName]=t.name);const a=this.organizationFqdn&&first(this.value(e,this.organizationFqdn)),n=this.organizationRequest(t),s=a?{fqdn:a,ready:!0}:await this.provider.find(n);if(first(this.value(e,this.sentAttribute)))return s?.ready?await this.organizationReady(e,s.fqdn):await this.releaseMembers(e.dn),"ready";let o=s?.ready&&s.fqdn;return s||(this.provider instanceof r&&await this.provider.createOrganization(t),o=await this.provider.create(n)),o?(await this.organizationReady(e,o),"ready"):"pending"})}organizationRequest(t){return{id:t.id,email:this.organizationMail(t.id,t.domain),publicName:t.name,locale:this.locale,orgId:t.id,orgDomain:t.domain,offer:this.config.twake_instance_cloudery_organization_offer}}async requestOrganization(t){if(!(this.provider instanceof r))return;const i=await this.findOrganization(t);if(!i||first(this.value(i,this.sentAttribute)))return;const e=first(this.value(i,this.organizationName)),a=first(this.value(i,this.organizationDomain));if(!e||!a)return void this.logger.warn(`${this.name}: organization ${t} has no ${this.organizationName} or ${this.organizationDomain}, so its instance cannot be asked for`);const n=this.organizationRequest({id:t,name:e,domain:a});if(!await this.provider.find(n)){await this.provider.createOrganization({id:t,name:e,domain:a});try{await this.provider.create(n)}catch(t){if(!await this.provider.find(n))throw t}}}async onWorkplaceCreated(t){const{internalEmail:i,workplaceFqdn:e}=t;if(!i||!e)return;const a=t.twakeId&&await this.findOrganization(t.twakeId);if(a){const n=t.twakeId.toLowerCase(),r=first(this.value(a,this.organizationDomain)),s=i.toLowerCase();if(r?this.organizationMail(n,r)===s:s.startsWith(`${n}@`))return this.serialized(`organization:${t.twakeId}`,async()=>{const t=await this.read(a.dn);t&&await this.organizationReady(t,e)})}const n=await this.findByMail(i);n?await this.settle(n.dn,e):this.logger.debug(`${this.name}: no account here for ${i}`)}async settle(t,i){await this.serialized(t,async()=>{const e=await this.read(t),a=e&&this.account(t,e);e&&a&&(first(this.value(e,this.sentAttribute))||await this.ready(t,e,a,i))})}async provide(t,i,e,a){e.orgId&&await this.requestOrganization(e.orgId);const n=await this.provider.find(e),r=n?n.ready&&n.fqdn:await this.provider.create(e);return r?(await this.ready(t,i,e,r,a),"ready"):"pending"}async ready(t,i,e,a,n){if(first(this.value(i,this.fqdnAttribute))!==a&&await this.server.ldap.modify(t,{replace:{[this.fqdnAttribute]:a}},n),first(this.value(i,this.sentAttribute)))return;const s=this.provider instanceof r&&e.orgId&&await this.findOrganization(e.orgId);if(s&&!first(this.value(s,this.sentAttribute))){if(t.toLowerCase().endsWith(`,${s.dn.toLowerCase()}`))return void this.logger.info(`${this.name}: ${t} waits for organization ${e.orgId}`);this.logger.warn(`${this.name}: ${t} is not below its organization entry, so it is announced before organization ${e.orgId} is linked; clear its ${this.sentAttribute} and send it again once the organization is announced`)}await this.announce(this.config.twake_instance_auth_exchange||"auth",this.config.twake_instance_user_created_key||"user.created",{twakeId:e.twakeId,internalEmail:e.email,workplaceFqdn:a,...e.orgId?{organizationId:e.orgId}:{},...e.orgDomain?{domain:e.orgDomain,organizationDomain:e.orgDomain}:{},...e.phone?{mobile:e.phone}:{}}),await this.server.ldap.modify(t,{replace:{[this.sentAttribute]:(new Date).toISOString()}},n)}async organizationReady(t,i){const e=t.dn,a=first(this.value(t,this.organizationId));if(a&&this.provider instanceof r&&await this.provider.linkOrganization(a,i),first(this.value(t,this.sentAttribute)))return this.releaseMembers(e);this.organizationFqdn&&!first(this.value(t,this.organizationFqdn))&&await this.server.ldap.modify(e,{replace:{[this.organizationFqdn]:i}});const n=first(this.value(t,this.organizationDomain)),s=first(this.value(t,this.organizationName));await this.announce(this.config.twake_instance_b2b_exchange||"b2b",this.config.twake_instance_organization_created_key||"organization.created",{organizationId:a,workplaceFqdn:i,...s?{organization:s}:{},...n?{domain:n}:{}}),await this.server.ldap.modify(e,{replace:{[this.sentAttribute]:(new Date).toISOString()}}),await this.releaseMembers(e)}async releaseMembers(t){const i=await this.server.ldap.search({paged:!1,scope:"sub",filter:`(&(${this.fqdnAttribute}=*)(!(${this.sentAttribute}=*)))`},t);let e=0;for(const t of i.searchEntries)try{await this.release(t)}catch(i){e++,this.logger.error(`${this.name}: ${t.dn} not released: ${String(i)}`)}if(e)throw new Error(`${this.name}: ${e} member(s) of ${t} not released`)}async release(t){const i=t.dn,e=this.account(i,t),a=first(this.value(t,this.fqdnAttribute));if(!e||!a)return;const n=a.toLowerCase()===this.provider.address(e)?{fqdn:a,ready:!0}:await this.provider.find(e,a);if(n?.ready)return this.settle(i,n.fqdn);this.logger.warn(`${this.name}: ${i} stays held, its address ${a} is not confirmed; ensureInstance sends it once it is`)}async serialized(t,i){const e=t.toLowerCase(),a=(this.queues.get(e)??Promise.resolve()).catch(()=>{}).then(i),n=a.catch(()=>{});return this.queues.set(e,n),n.then(()=>{this.queues.get(e)===n&&this.queues.delete(e)}),a}get organizationId(){return this.config.twake_instance_organization_id_attribute||"ou"}get organizationDomain(){return this.config.twake_instance_organization_domain_attribute||"twakeDomain"}get organizationName(){return this.config.twake_instance_organization_name_attribute||"description"}get organizationFqdn(){return this.config.twake_instance_organization_fqdn_attribute||""}organizationMail(t,i){return`${t}@${i}`.toLowerCase()}isOrganization(t){const i=this.config.twake_instance_organization_base;if(!i)return!1;return t.slice(t.indexOf(",")+1).toLowerCase()===i.toLowerCase()}async findOrganization(t){const i=this.config.twake_instance_organization_base;if(!i)return;return(await this.server.ldap.search({paged:!1,scope:"one",filter:`(${this.organizationId}=${h(t)})`},i)).searchEntries[0]}async read(t,i){try{return(await this.server.ldap.search({paged:!1,scope:"base"},t,i)).searchEntries[0]}catch(i){const a=s(i);if(32===a)return;if(34===a)throw new e(`Invalid DN: ${t}`);throw i}}async announce(t,i,e){const a=this.requirePlugin("rabbitmq");if(!a||!await a.getRawClient())throw new Error(`${this.name}: RabbitMQ unreachable, ${i} not sent`);await a.publish(t,i,e),this.logger.info({plugin:this.name,event:i,exchange:t})}account(t,i){const e=this.match(t);if(!e)return;if(u(i,this.lifecycle))return;const a=this.config.twake_instance_skip_attribute;if(a&&first(this.value(i,a))?.toLowerCase()===(this.config.twake_instance_skip_value||"").toLowerCase())return;const r=first(this.value(i,"mail")),s=first(this.value(i,"uid"));if(!r||!s)return void this.logger.warn(`${this.name}: ${t} has no mail or uid, no instance`);const o=this.provider instanceof n,c=e.groups?.org||(o?this.config.twake_instance_cozy_org_id:"")||void 0,h=e.groups?.org?r.split("@")[1]:o&&this.config.twake_instance_cozy_org_domain||void 0;return{id:this.id(s,e),...o?{oidc:s}:{},twakeId:s,email:r,publicName:first(this.value(i,"displayName"))||first(this.value(i,"cn"))||s,locale:first(this.value(i,"preferredLanguage"))||this.locale,phone:first(this.value(i,"mobile")),...c?{orgId:c}:{},...h?{orgDomain:h}:{}}}match(t){return this.patterns.map(i=>i.exec(t)).find(t=>null!==t)}id(t,i){const e={uid:t,...i.groups};return(this.config.twake_instance_id||"{uid}").replace(/\{(\w+)\}/g,(t,i)=>e[i]??"").replace(/\./g,"").toLowerCase()}value(t,i){const e=i.toLowerCase(),a=Object.keys(t).find(t=>t.toLowerCase()===e);return a?t[a]:void 0}async findByMail(t,i){return(await this.server.ldap.search({paged:!1,scope:"sub",filter:`(mail=${h(t)})`},this.config.ldap_base,i)).searchEntries.find(t=>this.patterns.some(i=>i.test(t.dn)))}}export{TwakeInstances as default};
@@ -1 +1 @@
1
- import{DEFAULT_LOCK_ATTRIBUTE as e,resolveLockConfig as t}from"../scim/mapping.js";function lifecycleAttributes(r){const l=r.twake_lifecycle_deleted_at_format||"iso8601";if("iso8601"!==l&&"generalizedTime"!==l)throw new Error(`--twake-lifecycle-deleted-at-format must be iso8601 or generalizedTime, got '${l}'`);const o=r.twake_lifecycle_lock_attribute?.trim(),i=r.scim_user_lock_attribute?.trim()||e,a=t(o||i,r.twake_lifecycle_lock_value?.trim()||(o&&o.toLowerCase()!==i.toLowerCase()?"":r.scim_user_lock_value||""),{attribute:"--twake-lifecycle-lock-attribute",value:"--twake-lifecycle-lock-value"});return{role:r.twake_lifecycle_role_attribute||"",lock:a.attribute,lockValue:a.value,deleted:r.twake_lifecycle_deleted_attribute||"",deletedValue:r.twake_lifecycle_deleted_value||"TRUE",deletedAt:r.twake_lifecycle_deleted_at_attribute||"",deletedAtFormat:l,reason:r.twake_lifecycle_reason_attribute||""}}function valueOf(e,t){if(!t)return;if(t in e)return e[t];const r=t.toLowerCase();for(const t of Object.keys(e))if(t.toLowerCase()===r)return e[t]}function first(e){const t=Array.isArray(e)?e[0]:e;if(null==t)return;const r=Buffer.isBuffer(t)?t.toString():String(t);return""===r?void 0:r}function holds(e,t,r){const l=valueOf(e,t);if(void 0===l)return!1;const o=r.toLowerCase();return(Array.isArray(l)?l:[l]).some(e=>String(e).toLowerCase()===o)}const isTombstone=(e,t)=>holds(e,t.deleted,t.deletedValue),isLocked=(e,t)=>holds(e,t.lock,t.lockValue);function formatDeletedAt(e,t){const r=e.toISOString();return"iso8601"===t?r:r.replace(/[-:T]/g,"").replace(/\.\d+Z$/,"Z")}function parseDeletedAt(e,t){if("iso8601"===t){const t=new Date(e);return isNaN(t.getTime())?void 0:t}const r=/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})?(?:[.,](\d+))?Z$/.exec(e);if(r)return new Date(Date.UTC(+r[1],+r[2]-1,+r[3],+r[4],+r[5],+(r[6]||0),Math.round(1e3*+`0.${r[7]||0}`)))}export{first,formatDeletedAt,isLocked,isTombstone,lifecycleAttributes,parseDeletedAt,valueOf};
1
+ import{DEFAULT_LOCK_ATTRIBUTE as e,resolveLockConfig as t,holdsValue as r}from"../scim/mapping.js";function lifecycleAttributes(r){const l=r.twake_lifecycle_deleted_at_format||"iso8601";if("iso8601"!==l&&"generalizedTime"!==l)throw new Error(`--twake-lifecycle-deleted-at-format must be iso8601 or generalizedTime, got '${l}'`);const i=r.twake_lifecycle_lock_attribute?.trim(),o=r.scim_user_lock_attribute?.trim()||e,a=t(i||o,r.twake_lifecycle_lock_value?.trim()||(i&&i.toLowerCase()!==o.toLowerCase()?"":r.scim_user_lock_value||""),{attribute:"--twake-lifecycle-lock-attribute",value:"--twake-lifecycle-lock-value"});return{role:r.twake_lifecycle_role_attribute||"",lock:a.attribute,lockValue:a.value,deleted:r.twake_lifecycle_deleted_attribute||"",deletedValue:r.twake_lifecycle_deleted_value||"TRUE",deletedAt:r.twake_lifecycle_deleted_at_attribute||"",deletedAtFormat:l,reason:r.twake_lifecycle_reason_attribute||""}}function valueOf(e,t){if(!t)return;if(t in e)return e[t];const r=t.toLowerCase();for(const t of Object.keys(e))if(t.toLowerCase()===r)return e[t]}function first(e){const t=Array.isArray(e)?e[0]:e;if(null==t)return;const r=Buffer.isBuffer(t)?t.toString():String(t);return""===r?void 0:r}const isTombstone=(e,t)=>r(e,t.deleted,t.deletedValue),isLocked=(e,t)=>r(e,t.lock,t.lockValue);function formatDeletedAt(e,t){const r=e.toISOString();return"iso8601"===t?r:r.replace(/[-:T]/g,"").replace(/\.\d+Z$/,"Z")}function parseDeletedAt(e,t){if("iso8601"===t){const t=new Date(e);return isNaN(t.getTime())?void 0:t}const r=/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})?(?:[.,](\d+))?Z$/.exec(e);if(r)return new Date(Date.UTC(+r[1],+r[2]-1,+r[3],+r[4],+r[5],+(r[6]||0),Math.round(1e3*+`0.${r[7]||0}`)))}export{first,formatDeletedAt,isLocked,isTombstone,lifecycleAttributes,parseDeletedAt,valueOf};
@@ -1 +1 @@
1
- import{randomUUID as e}from"node:crypto";import t from"node:fs";import r from"../../abstract/plugin.js";import{unescapeDnValue as s,parseDn as i}from"../../lib/utils.js";import{DEFAULT_LOCK_ATTRIBUTE as n,DEFAULT_LOCK_VALUE as o}from"../scim/mapping.js";import{lifecycleAttributes as a,isTombstone as l,isLocked as c,valueOf as h,first as u,parseDeletedAt as f}from"./lifecycleAttributes.js";const d=["created","roleChanged","disabled","enabled","deleted"];const isObject=e=>"object"==typeof e&&null!==e&&!Array.isArray(e);function optionalString(e,t,r){if(void 0!==e&&"string"!=typeof e)throw new Error(`${t}: "${r}" must be a string`)}function optionalPayload(e,t,r){if(void 0!==e){if(!isObject(e)||Object.values(e).some(e=>"string"!=typeof e))throw new Error(`${t}: "${r}" must be an object of strings`);return e}}function parseRules(e){if(!e.trim())return[];const r=/^\s*[[{]/.test(e)?e:t.readFileSync(e,"utf8"),s=JSON.parse(r);if(!Array.isArray(s))throw new Error("--twake-lifecycle-rules must be a JSON array of rules");return s.map(e=>{if(!isObject(e))throw new Error("Every lifecycle rule must be an object");if("string"!=typeof e.dn)throw new Error('Every lifecycle rule needs a "dn" pattern');const{dn:t}=e,r=`Lifecycle rule ${t}`;optionalString(e.exchange,r,"exchange");const s=optionalPayload(e.payload,r,"payload");if(void 0!==e.events&&!isObject(e.events))throw new Error(`${r}: "events" must be an object`);const i={};for(const[r,n]of Object.entries(e.events||{})){if(!d.includes(r))throw new Error(`Unknown lifecycle event "${r}"; known: ${d.join(", ")}`);const o=`Lifecycle event "${r}" of ${t}`;i[r]=(Array.isArray(n)?n:[n]).map(t=>{if("string"!=typeof t&&!isObject(t))throw new Error(`${o}: a target is a routing key or an object`);const r="string"==typeof t?{routingKey:t}:t;optionalString(r.routingKey,o,"routingKey"),optionalString(r.exchange,o,"exchange");const i=r.exchange||e.exchange,n=r.routingKey;if(!i||!n)throw new Error(`${o} needs an exchange and a routing key`);return{exchange:i,routingKey:n,payload:optionalPayload(r.payload,o,"payload")||s||{},when:optionalPayload(r.when,o,"when")||{}}})}return{dn:new RegExp(t,"i"),targets:i}})}class TwakeLifecycleEvents extends r{constructor(e){if(super(e),this.name="twakeLifecycleEvents",this.roles=["consistency"],this.dependencies={onLdapChange:"core/ldap/onChange",rabbitmq:"core/rabbitmq"},this.hooks={onLdapEntryChange:(e,t,r,s)=>this.onEntryChange(e,t,r,s),twakedeletionreplay:(e,t,r)=>this.replay(e,t,r)},this.attrs=a(this.config),this.followedOperationalAttributes=[this.attrs.lock],this.rules=parseRules(this.config.twake_lifecycle_rules||""),0===this.rules.length)this.logger.warn(`${this.name}: --twake-lifecycle-rules is empty, nothing will be published`);else if(!this.config.rabbitmq_url)throw new Error(`${this.name}: --twake-lifecycle-rules needs --rabbitmq-url`)}async assertComposition(){if(0!==this.rules.length&&!await(this.requirePlugin("rabbitmq")?.getRawClient()))throw new Error(`${this.name}: RabbitMQ at --rabbitmq-url cannot be reached`)}afterLoad(){const e=this.server.loadedPlugins.scim?.config;if(!e)return;const t=e.scim_user_lock_attribute?.trim()||n,r=e.scim_user_lock_value?.trim()||o,{lock:s,lockValue:i}=this.attrs;t.toLowerCase()===s.toLowerCase()&&r.toLowerCase()===i.toLowerCase()||this.logger.warn(`${this.name}: SCIM locks an account with ${t}: ${r}, these events read ${s}: ${i}, so a SCIM deactivation publishes no disabled`)}async replay(e,t,r){const s=this.match(e);s&&l(t,this.attrs)&&await this.publish(s.rule,"deleted",{before:t,after:t,dn:s.groups,change:r})}match(e){const t=function(e){return i(e).map(e=>{const t=e.indexOf("=");if(-1===t)return e;const r=e.slice(t+1).trim();return`${e.slice(0,t).trim()}=${r.replace(/\\,/g,"\\2C")}`}).join(",")}(e);for(const e of this.rules){const r=e.dn.exec(t);if(!r)continue;const i={};for(const[e,t]of Object.entries(r.groups||{}))void 0!==t&&(i[e]=s(t));return{rule:e,groups:i}}}async onEntryChange(e,t,r,s){const i=this.match(e);if(!i||t&&l(t,this.attrs))return;const{rule:n}=i,o={before:t||{},after:r||t||{},dn:i.groups,change:s};if(!r)return this.publish(n,"deleted",o);if(l(r,this.attrs))return t?this.publish(n,"deleted",o):void 0;if(!t)return await this.publish(n,"created",o),void(c(r,this.attrs)&&await this.publish(n,"disabled",o));this.attrs.role&&!function(e,t){const set=e=>new Set((void 0===e?[]:Array.isArray(e)?e:[e]).map(e=>e.toString().toLowerCase())),r=set(e),s=set(t);return r.size===s.size&&[...r].every(e=>s.has(e))}(h(t,this.attrs.role),h(r,this.attrs.role))&&await this.publish(n,"roleChanged",o);const a=c(r,this.attrs);a!==c(t,this.attrs)&&await this.publish(n,a?"disabled":"enabled",o)}resolve(e,t){if(!e.startsWith("$"))return e;if("$now"===e)return(new Date).toISOString();let r=e.slice(1);const s=r.endsWith("|domain");let i;s&&(r=r.slice(0,-7));let n=r;return r.startsWith("dn.")?(i=t.dn[r.slice(3)],n=""):r.startsWith("context.")?(i=t.change[r.slice(8)],n=""):r.startsWith("previous.")?(n=r.slice(9),i=u(h(t.before,n))):i=u(h(t.after,n)),void 0!==i&&this.attrs.deletedAt&&n.toLowerCase()===this.attrs.deletedAt.toLowerCase()&&(i=f(i,this.attrs.deletedAtFormat)?.toISOString()),s&&(i=i?.split("@")[1]),i}async publish(t,r,s){const i=t.targets[r];if(!i?.length)return;const n=this.requirePlugin("rabbitmq");if(n)for(const t of i){if(!Object.entries(t.when).every(([e,t])=>{const r=this.resolve(e,s);return t.startsWith("!")?r!==t.slice(1):r===t}))continue;const i={};for(const[e,r]of Object.entries(t.payload)){const t=this.resolve(r,s);void 0!==t&&(i[e]=t)}const o=e(),a={plugin:this.name,event:r,exchange:t.exchange,routingKey:t.routingKey,messageId:o};if(await n.getRawClient())try{await n.publish(t.exchange,t.routingKey,i,{messageId:o}),this.logger.info({...a,result:"published"})}catch(e){this.logger.error({...a,result:"error",error:String(e)})}else this.logger.error({...a,result:"no broker"})}}}export{d as LIFECYCLE_EVENTS,TwakeLifecycleEvents as default,parseRules};
1
+ import{randomUUID as e}from"node:crypto";import t from"node:fs";import r from"../../abstract/plugin.js";import{extractLdapCode as i}from"../../lib/ldapCodes.js";import{unescapeDnValue as s,isDummyMemberDn as n,parseDn as o,normalizeDn as a}from"../../lib/utils.js";import{DEFAULT_LOCK_ATTRIBUTE as c,DEFAULT_LOCK_VALUE as l}from"../scim/mapping.js";import{lifecycleAttributes as h,isTombstone as u,isLocked as d,valueOf as f,first as m,parseDeletedAt as b}from"./lifecycleAttributes.js";const g=["created","roleChanged","disabled","enabled","updated","memberAdded","memberRemoved","deleted"],p=["$members","$added","$removed"];const isObject=e=>"object"==typeof e&&null!==e&&!Array.isArray(e);function optionalString(e,t,r){if(void 0!==e&&"string"!=typeof e)throw new Error(`${t}: "${r}" must be a string`)}const isStrings=e=>isObject(e)&&Object.values(e).every(e=>"string"==typeof e);function optionalCondition(e,t,r){if(void 0!==e){if(!isStrings(e))throw new Error(`${t}: "${r}" must be an object of strings`);return e}}const y=/^(?:[^$].*|\$(?!now$)[A-Za-z][A-Za-z0-9-]*)$/;function memberList(e){if(!isObject(e))return;const t=Object.entries(e);if(1!==t.length)return;const[r,i]=t[0];return p.includes(r)&&isStrings(i)&&Object.values(i).every(e=>y.test(e))?{list:r,fields:i}:void 0}function optionalPayload(e,t,r){if(void 0===e)return;const i=isObject(e)?Object.entries(e):void 0,s={};for(const[e,t]of i||[]){const r="string"==typeof t?t:memberList(t);if(void 0===r)break;s[e]=r}if(!i||Object.keys(s).length!==i.length)throw new Error(`${t}: "${r}" must be an object of strings, or of ${p.join(", ")} each mapped to an object of member attributes ($attr) or plain values`);return s}function dnKey(e){try{return a(e)}catch{return e.toLowerCase()}}function parseRules(e){if(!e.trim())return[];const r=/^\s*[[{]/.test(e)?e:t.readFileSync(e,"utf8"),i=JSON.parse(r);if(!Array.isArray(i))throw new Error("--twake-lifecycle-rules must be a JSON array of rules");return i.map(e=>{if(!isObject(e))throw new Error("Every lifecycle rule must be an object");if("string"!=typeof e.dn)throw new Error('Every lifecycle rule needs a "dn" pattern');const{dn:t}=e,r=`Lifecycle rule ${t}`;optionalString(e.exchange,r,"exchange");const i=optionalPayload(e.payload,r,"payload");if(void 0!==e.events&&!isObject(e.events))throw new Error(`${r}: "events" must be an object`);const s={};for(const[r,n]of Object.entries(e.events||{})){if(!g.includes(r))throw new Error(`Unknown lifecycle event "${r}"; known: ${g.join(", ")}`);const o=`Lifecycle event "${r}" of ${t}`;s[r]=(Array.isArray(n)?n:[n]).map(t=>{if("string"!=typeof t&&!isObject(t))throw new Error(`${o}: a target is a routing key or an object`);const r="string"==typeof t?{routingKey:t}:t;optionalString(r.routingKey,o,"routingKey"),optionalString(r.exchange,o,"exchange");const s=r.exchange||e.exchange,n=r.routingKey;if(!s||!n)throw new Error(`${o} needs an exchange and a routing key`);return{exchange:s,routingKey:n,payload:optionalPayload(r.payload,o,"payload")||i||{},when:optionalCondition(r.when,o,"when")||{}}})}return{dn:new RegExp(t,"i"),targets:s}})}class TwakeLifecycleEvents extends r{constructor(e){if(super(e),this.name="twakeLifecycleEvents",this.roles=["consistency"],this.dependencies={onLdapChange:"core/ldap/onChange",rabbitmq:"core/rabbitmq"},this.hooks={onLdapEntryChange:(e,t,r,i)=>this.onEntryChange(e,t,r,i),twakedeletionreplay:(e,t,r)=>this.replay(e,t,r)},this.attrs=h(this.config),this.memberAttribute=this.config.twake_lifecycle_member_attribute||"member",this.followedOperationalAttributes=[this.attrs.lock],this.rules=parseRules(this.config.twake_lifecycle_rules||""),0===this.rules.length)this.logger.warn(`${this.name}: --twake-lifecycle-rules is empty, nothing will be published`);else if(!this.config.rabbitmq_url)throw new Error(`${this.name}: --twake-lifecycle-rules needs --rabbitmq-url`)}async assertComposition(){if(0!==this.rules.length&&!await(this.requirePlugin("rabbitmq")?.getRawClient()))throw new Error(`${this.name}: RabbitMQ at --rabbitmq-url cannot be reached`)}afterLoad(){const e=this.server.loadedPlugins.scim?.config;if(!e)return;const t=e.scim_user_lock_attribute?.trim()||c,r=e.scim_user_lock_value?.trim()||l,{lock:i,lockValue:s}=this.attrs;t.toLowerCase()===i.toLowerCase()&&r.toLowerCase()===s.toLowerCase()||this.logger.warn(`${this.name}: SCIM locks an account with ${t}: ${r}, these events read ${i}: ${s}, so a SCIM deactivation publishes no disabled`)}async replay(e,t,r){const i=this.match(e);i&&u(t,this.attrs)&&await this.publish(i.rule,"deleted",{before:t,after:t,dn:i.groups,change:r})}match(e){const t=function(e){return o(e).map(e=>{const t=e.indexOf("=");if(-1===t)return e;const r=e.slice(t+1).trim();return`${e.slice(0,t).trim()}=${r.replace(/\\,/g,"\\2C")}`}).join(",")}(e);for(const e of this.rules){const r=e.dn.exec(t);if(!r)continue;const i={};for(const[e,t]of Object.entries(r.groups||{}))void 0!==t&&(i[e]=s(t));return{rule:e,groups:i}}}async onEntryChange(e,t,r,i){const s=this.match(e);if(!s||t&&u(t,this.attrs))return;const{rule:n}=s,o={before:t||{},after:r||t||{},dn:s.groups,change:i};if(!r)return this.publish(n,"deleted",o);if(u(r,this.attrs))return t?this.publish(n,"deleted",o):void 0;if(!t)return await this.publish(n,"created",o),void(d(r,this.attrs)&&await this.publish(n,"disabled",o));this.attrs.role&&!function(e,t){const set=e=>new Set((void 0===e?[]:Array.isArray(e)?e:[e]).map(e=>e.toString().toLowerCase())),r=set(e),i=set(t);return r.size===i.size&&[...r].every(e=>i.has(e))}(f(t,this.attrs.role),f(r,this.attrs.role))&&await this.publish(n,"roleChanged",o);const a=d(r,this.attrs);a!==d(t,this.attrs)&&await this.publish(n,a?"disabled":"enabled",o),await this.publish(n,"updated",o);const c=this.memberDiff(r,t),l=this.memberDiff(t,r);c.length&&await this.publish(n,"memberAdded",{...o,added:c}),l.length&&await this.publish(n,"memberRemoved",{...o,removed:l})}members(e){const t=f(e,this.memberAttribute);return(void 0===t?[]:Array.isArray(t)?t:[t]).map(e=>e.toString()).filter(e=>!n(e,this.config.group_dummy_user))}memberDiff(e,t){const r=new Set(this.members(t).map(dnKey));return this.members(e).filter(e=>!r.has(dnKey(e)))}async memberPayloads({list:e,fields:t},r){const i="$added"===e?r.added||[]:"$removed"===e?r.removed||[]:this.members(r.after),s=Object.values(t).filter(e=>e.startsWith("$")).map(e=>e.slice(1));this.attrs.deleted&&s.push(this.attrs.deleted);const n=[];for(const e of i){const r=await this.readMember(e,s);if(!r||u(r,this.attrs))continue;const i={};for(const[e,s]of Object.entries(t)){const t=s.startsWith("$")?m(f(r,s.slice(1))):s;void 0!==t&&(i[e]=t)}n.push(i)}return n}async readMember(e,t){try{const{searchEntries:r}=await this.server.ldap.search({paged:!1,scope:"base",attributes:t},e);if(r[0])return r[0]}catch(e){if(32!==i(e))throw e}const[r]=o(e),n=r?.indexOf("=")??-1;if(-1!==n)return{[r.slice(0,n).trim()]:s(r.slice(n+1).trim())}}resolve(e,t){if(!e.startsWith("$"))return e;if("$now"===e)return(new Date).toISOString();let r=e.slice(1);const i=r.endsWith("|domain");let s;i&&(r=r.slice(0,-7));let n=r;if(r.startsWith("dn."))s=t.dn[r.slice(3)],n="";else if(r.startsWith("context."))s=t.change[r.slice(8)],n="";else if(r.startsWith("previous."))n=r.slice(9),s=m(f(t.before,n));else if(r.startsWith("changed.")){n=r.slice(8);const e=f(t.after,n);(function(e,t){const list=e=>(void 0===e?[]:Array.isArray(e)?e:[e]).map(e=>e.toString()).sort();return list(e).join("\0")===list(t).join("\0")})(f(t.before,n),e)||(s=m(e)??"")}else s=m(f(t.after,n));return void 0!==s&&this.attrs.deletedAt&&n.toLowerCase()===this.attrs.deletedAt.toLowerCase()&&(s=b(s,this.attrs.deletedAtFormat)?.toISOString()),i&&(s=s?.split("@")[1]),s}async publish(t,r,i){const s=t.targets[r];if(!s?.length)return;const n=this.requirePlugin("rabbitmq");if(n)for(const t of s){if(!Object.entries(t.when).every(([e,t])=>{const r=this.resolve(e,i);return t.startsWith("!")?r!==t.slice(1):r===t}))continue;const s={};let o=!1,a=!1,c=!1;try{for(const[e,r]of Object.entries(t.payload)){if("string"!=typeof r){const t=await this.memberPayloads(r,i);t.length||"$members"===r.list||(c=!0),s[e]=t;continue}const t=this.resolve(r,i);r.startsWith("$changed.")&&(o=!0,void 0!==t&&(a=!0)),void 0!==t&&(s[e]=t)}}catch(e){this.logger.error({plugin:this.name,event:r,routingKey:t.routingKey,result:"members unreadable",error:String(e)});continue}if(c||o&&!a)continue;const l=e(),h={plugin:this.name,event:r,exchange:t.exchange,routingKey:t.routingKey,messageId:l};if(await n.getRawClient())try{await n.publish(t.exchange,t.routingKey,s,{messageId:l}),this.logger.info({...h,result:"published"})}catch(e){this.logger.error({...h,result:"error",error:String(e)})}else this.logger.error({...h,result:"no broker"})}}}export{g as LIFECYCLE_EVENTS,TwakeLifecycleEvents as default,parseRules};
@@ -1 +1 @@
1
- import e from"../../abstract/plugin.js";import{BadRequestError as t,NotFoundError as s,ConflictError as r}from"../../lib/errors.js";import{jsonBody as a}from"../../lib/expressFormatedResponses.js";import{changeContext as o}from"../../lib/changeContext.js";import{extractLdapCode as i}from"../../lib/ldapCodes.js";import{escapeLdapFilter as n,asyncHandler as h,launchHooks as l}from"../../lib/utils.js";import{isTombstone as d,lifecycleAttributes as c,first as f,valueOf as m,parseDeletedAt as p,formatDeletedAt as u}from"./lifecycleAttributes.js";const w=/^[A-Za-z0-9_.-]{1,64}$/;class TwakeTombstone extends e{constructor(e){super(e),this.name="twakeTombstone",this.roles=["consistency","api"],this.reasonFor=new Map,this.erasing=new Set,this.hooks={ldapdeleterequest:async([e,t])=>{const s=[];for(const r of Array.isArray(e)?e:[e]){if(!this.erasing.has(r)&&this.matches(r)){const e=await this.read(r);if(e){await this.writeTombstone(r,e,this.reasonOf(r,t),t);continue}}s.push(r)}return[s,t]},ldapaddrequest:async([e,t,s])=>{if(this.isScim(s)&&this.matches(e)){const t=await this.read(e);t&&d(t,this.attrs)&&await this.replace(e,t,s)}return[e,t,s]},ldapsearchrequest:([e,t,s])=>{if(!this.isScim(s))return[e,t,s];const r=t.filter||"(objectClass=*)",a=`(!(${this.attrs.deleted}=${n(this.attrs.deletedValue)}))`;return[e,{...t,filter:`(&${String(r).startsWith("(")?String(r):`(${String(r)})`}${a})`},s]}};const t=this.config;if(this.attrs=c(t),!this.attrs.deleted)throw new Error(`${this.name}: --twake-lifecycle-deleted-attribute is required`);this.patterns=(t.twake_tombstone_dn||[]).map(e=>new RegExp(e,"i")),this.reasons=t.twake_tombstone_reasons||[],this.defaultReason=this.checkReason(t.twake_tombstone_default_reason||"deleted"),this.reasonHeader=(t.twake_tombstone_reason_header||"x-deletion-reason").toLowerCase(),this.clearAttributes=t.twake_tombstone_clear_attributes||[],this.eraseMinAge=t.twake_tombstone_erase_min_age??2592e3,this.groupBases=t.twake_tombstone_group_bases?.length?t.twake_tombstone_group_bases:[t.ldap_group_base||t.ldap_base||""],this.scimPrefix=t.scim_prefix||"/scim/v2",0===this.patterns.length&&this.logger.warn(`${this.name}: --twake-tombstone-dn is empty, every delete stays a delete`)}afterLoad(){for(const e of["ldapdeleterequest","ldapaddrequest"]){const t=this.server.hooks[e],s=this.hooks[e],r=t&&s?t.indexOf(s):-1;r<0||(t.splice(r,1),t.push(s))}}api(e){e.post(`${this.config.api_prefix}/v1/twake/tombstones/erase`,h(async(e,s)=>{const r=a(e,s,"dn");if(r){if("string"!=typeof r.dn)throw new t("Field dn must be a string");await this.erase(r.dn,{force:!0===r.force,req:e}),s.json({success:!0})}}))}async tombstone(e,s,r){if(!this.matches(e))throw new t(`${e} is not an entry that becomes a tombstone`);this.reasonFor.set(e,this.checkReason(s));try{await this.server.ldap.delete(e,r)}finally{this.reasonFor.delete(e)}}async erase(e,{force:t=!1,req:a}={}){const o=await this.read(e);if(!o||!d(o,this.attrs))throw new s(`No tombstone at ${e}`);if(!t){const t=f(m(o,this.attrs.deletedAt)),s=t&&p(t,this.attrs.deletedAtFormat);if(!s)throw new r(`${e} holds no readable deletion date (--twake-lifecycle-deleted-at-attribute); force it to erase now`);if(Date.now()-s.getTime()<1e3*this.eraseMinAge)throw new r(`${e} was deleted less than ${this.eraseMinAge} seconds ago; force it to erase now`)}this.erasing.add(e);try{await this.server.ldap.delete(e,a)}finally{this.erasing.delete(e)}await this.leaveGroups(e),this.logger.info({plugin:this.name,event:"erase",dn:e,force:t})}assertComposition(){if(!this.server.loadedPlugins.trash)return;const e=String(this.config.trash_watched_bases||"").split(",").map(e=>e.trim().toLowerCase()).filter(Boolean),t=this.patterns.filter(t=>{const s=t.source.toLowerCase().replace(/\$$/,"");return 0===e.length||e.some(e=>s===e||s.endsWith(`,${e}`))});if(t.length)throw new Error(`${this.name}: core/ldap/trash watches the branch of ${t.map(e=>e.source).join(", ")}, and would move those entries instead of leaving a tombstone. Leave that branch out of --trash-watched-bases, or load one of the two plugins only`)}async replace(e,t,s){const r=await this.groupsOf(e),a=this.config.group_dummy_user,o=new Set(a?await this.groupsOf(a):[]);await this.erase(e,{force:!0,req:s});const i=s.res;if(!i)return;const n=i.end.bind(i);i.end=(...s)=>{i.end=n;const a=n(...s);return this.restore(e,t,r,o).catch(t=>this.logger.error({plugin:this.name,event:"restore",dn:e,error:String(t)})),a}}async restore(e,t,s,r){if(await this.read(e))return;const a=Object.fromEntries(Object.entries(t).filter(([e,t])=>"dn"!==e&&!(Array.isArray(t)&&0===t.length)));await this.server.ldap.add(e,a);const o=this.config.group_dummy_user;for(const t of s)await this.server.ldap.modify(t,{add:{member:e}}),o&&!r.has(t)&&await this.server.ldap.modify(t,{delete:{member:o}}).catch(e=>{if(16!==i(e))throw e});this.logger.warn({plugin:this.name,event:"restore",dn:e})}matches(e){return this.patterns.some(t=>t.test(e))}isScim(e){const t=e?.originalUrl??e?.url;return"string"==typeof t&&t.startsWith(this.scimPrefix)}checkReason(e){if(!(this.reasons.length?this.reasons.includes(e):w.test(e)))throw new t(`Unknown deletion reason "${e}"${this.reasons.length?`; known: ${this.reasons.join(", ")}`:""}`);return e}reasonOf(e,t){const s=this.reasonFor.get(e);if(s)return s;const r=t?.headers?.[this.reasonHeader],a=Array.isArray(r)?r[0]:r;return a?this.checkReason(a):this.defaultReason}async read(e){try{return(await this.server.ldap.search({paged:!1,scope:"base",attributes:["*",this.attrs.lock]},e)).searchEntries[0]}catch(e){if(32===i(e))return;throw e}}async writeTombstone(e,t,s,r){const{attrs:a}=this;if(d(t,a))return l(this.server.hooks.twakedeletionreplay,e,t,o(r)),void this.logger.info({plugin:this.name,event:"replay",dn:e});const i={[a.deleted]:a.deletedValue,[a.lock]:a.lockValue};a.deletedAt&&(i[a.deletedAt]=u(new Date,a.deletedAtFormat)),a.reason&&(i[a.reason]=s);const n=this.clearAttributes.filter(e=>void 0!==m(t,e));await this.server.ldap.modify(e,n.length?{replace:i,delete:n}:{replace:i}),this.logger.info({plugin:this.name,event:"tombstone",dn:e,reason:s})}async groupsOf(e){const t=[];for(const s of this.groupBases.filter(Boolean))try{const r=await this.server.ldap.search({paged:!1,scope:"sub",filter:`(member=${n(e)})`,attributes:["dn"]},s);t.push(...r.searchEntries.map(e=>e.dn))}catch(e){if(32!==i(e))throw e}return t}async leaveGroups(e){for(const t of await this.groupsOf(e))await this.leaveGroup(t,e)}async leaveGroup(e,t){const s=this.config.group_dummy_user;let r=!1;for(let a=0;;a++)try{return void await this.server.ldap.modify(e,r?{add:{member:s},delete:{member:t}}:{delete:{member:t}})}catch(e){const t=i(e);if(16===t)return;if(a>=2)throw e;if(65===t&&s)r=!0;else{if(20!==t)throw e;r=!1}}}}export{TwakeTombstone as default};
1
+ import e from"../../abstract/plugin.js";import{BadRequestError as t,NotFoundError as s,ConflictError as r}from"../../lib/errors.js";import{jsonBody as a}from"../../lib/expressFormatedResponses.js";import{changeContext as i}from"../../lib/changeContext.js";import{extractLdapCode as o}from"../../lib/ldapCodes.js";import{escapeLdapFilter as n,asyncHandler as h,isDnInBranch as l,launchHooks as d}from"../../lib/utils.js";import{isTombstone as c,lifecycleAttributes as f,first as m,valueOf as p,parseDeletedAt as u,formatDeletedAt as w}from"./lifecycleAttributes.js";const g=/^[A-Za-z0-9_.-]{1,64}$/;class TwakeTombstone extends e{constructor(e){super(e),this.name="twakeTombstone",this.roles=["consistency","api"],this.reasonFor=new Map,this.erasing=new Set,this.hooks={ldapdeleterequest:async([e,t])=>{const s=[];for(const r of Array.isArray(e)?e:[e]){if(!this.erasing.has(r)&&this.matches(r)){const e=await this.read(r);if(e){await this.writeTombstone(r,e,this.reasonOf(r,t),t);continue}}s.push(r)}return[s,t]},ldapaddrequest:async([e,t,s])=>{if(this.isScim(s)&&this.matches(e)){const t=await this.read(e);t&&c(t,this.attrs)&&await this.replace(e,t,s)}return[e,t,s]},ldapsearchrequest:([e,t,s])=>{if(!this.isScim(s))return[e,t,s];const r=t.filter||"(objectClass=*)",a=`(!(${this.attrs.deleted}=${n(this.attrs.deletedValue)}))`;return[e,{...t,filter:`(&${String(r).startsWith("(")?String(r):`(${String(r)})`}${a})`},s]}};const t=this.config;if(this.attrs=f(t),!this.attrs.deleted)throw new Error(`${this.name}: --twake-lifecycle-deleted-attribute is required`);this.patterns=(t.twake_tombstone_dn||[]).map(e=>new RegExp(e,"i")),this.reasons=t.twake_tombstone_reasons||[],this.defaultReason=this.checkReason(t.twake_tombstone_default_reason||"deleted"),this.reasonHeader=(t.twake_tombstone_reason_header||"x-deletion-reason").toLowerCase(),this.clearAttributes=t.twake_tombstone_clear_attributes||[],this.eraseMinAge=t.twake_tombstone_erase_min_age??2592e3,this.groupBases=t.twake_tombstone_group_bases?.length?t.twake_tombstone_group_bases:[t.ldap_group_base||t.ldap_base||""],this.scimPrefix=t.scim_prefix||"/scim/v2",0===this.patterns.length&&this.logger.warn(`${this.name}: --twake-tombstone-dn is empty, every delete stays a delete`)}afterLoad(){for(const e of["ldapdeleterequest","ldapaddrequest"]){const t=this.server.hooks[e],s=this.hooks[e],r=t&&s?t.indexOf(s):-1;r<0||(t.splice(r,1),t.push(s))}}api(e){e.post(`${this.config.api_prefix}/v1/twake/tombstones/erase`,h(async(e,s)=>{const r=a(e,s,"dn");if(r){if("string"!=typeof r.dn)throw new t("Field dn must be a string");await this.erase(r.dn,{force:!0===r.force,req:e}),s.json({success:!0})}}))}async tombstone(e,s,r){if(!this.matches(e))throw new t(`${e} is not an entry that becomes a tombstone`);this.reasonFor.set(e,this.checkReason(s));try{await this.server.ldap.delete(e,r)}finally{this.reasonFor.delete(e)}}async erase(e,{force:t=!1,req:a}={}){const i=await this.read(e);if(!i||!c(i,this.attrs))throw new s(`No tombstone at ${e}`);if(!t){const t=m(p(i,this.attrs.deletedAt)),s=t&&u(t,this.attrs.deletedAtFormat);if(!s)throw new r(`${e} holds no readable deletion date (--twake-lifecycle-deleted-at-attribute); force it to erase now`);if(Date.now()-s.getTime()<1e3*this.eraseMinAge)throw new r(`${e} was deleted less than ${this.eraseMinAge} seconds ago; force it to erase now`)}this.erasing.add(e);try{await this.server.ldap.delete(e,a)}finally{this.erasing.delete(e)}await this.leaveGroups(e),this.logger.info({plugin:this.name,event:"erase",dn:e,force:t})}assertComposition(){if(!this.server.loadedPlugins.trash)return;const e=String(this.config.trash_watched_bases||"").split(";").map(e=>e.trim()).filter(Boolean),t=this.patterns.filter(t=>{const s=t.source.replace(/\$$/,"");return 0===e.length||e.some(e=>l(s,e)||l(e,s))});if(t.length)throw new Error(`${this.name}: core/ldap/trash watches the branch of ${t.map(e=>e.source).join(", ")}, and would move those entries instead of leaving a tombstone. Leave that branch out of --trash-watched-bases, or load one of the two plugins only`)}async replace(e,t,s){const r=await this.groupsOf(e),a=this.config.group_dummy_user,i=new Set(a?await this.groupsOf(a):[]);await this.erase(e,{force:!0,req:s});const o=s.res;if(!o)return;const n=o.end.bind(o);o.end=(...s)=>{o.end=n;const a=n(...s);return this.restore(e,t,r,i).catch(t=>this.logger.error({plugin:this.name,event:"restore",dn:e,error:String(t)})),a}}async restore(e,t,s,r){if(await this.read(e))return;const a=Object.fromEntries(Object.entries(t).filter(([e,t])=>"dn"!==e&&!(Array.isArray(t)&&0===t.length)));await this.server.ldap.add(e,a);const i=this.config.group_dummy_user;for(const t of s)await this.server.ldap.modify(t,{add:{member:e}}),i&&!r.has(t)&&await this.server.ldap.modify(t,{delete:{member:i}}).catch(e=>{if(16!==o(e))throw e});this.logger.warn({plugin:this.name,event:"restore",dn:e})}matches(e){return this.patterns.some(t=>t.test(e))}isScim(e){const t=e?.originalUrl??e?.url;return"string"==typeof t&&t.startsWith(this.scimPrefix)}checkReason(e){if(!(this.reasons.length?this.reasons.includes(e):g.test(e)))throw new t(`Unknown deletion reason "${e}"${this.reasons.length?`; known: ${this.reasons.join(", ")}`:""}`);return e}reasonOf(e,t){const s=this.reasonFor.get(e);if(s)return s;const r=t?.headers?.[this.reasonHeader],a=Array.isArray(r)?r[0]:r;return a?this.checkReason(a):this.defaultReason}async read(e){try{return(await this.server.ldap.search({paged:!1,scope:"base",attributes:["*",this.attrs.lock]},e)).searchEntries[0]}catch(e){if(32===o(e))return;throw e}}async writeTombstone(e,t,s,r){const{attrs:a}=this;if(c(t,a))return d(this.server.hooks.twakedeletionreplay,e,t,i(r)),void this.logger.info({plugin:this.name,event:"replay",dn:e});const o={[a.deleted]:a.deletedValue,[a.lock]:a.lockValue};a.deletedAt&&(o[a.deletedAt]=w(new Date,a.deletedAtFormat)),a.reason&&(o[a.reason]=s);const n=this.clearAttributes.filter(e=>void 0!==p(t,e));await this.server.ldap.modify(e,n.length?{replace:o,delete:n}:{replace:o}),this.logger.info({plugin:this.name,event:"tombstone",dn:e,reason:s})}async groupsOf(e){const t=[];for(const s of this.groupBases.filter(Boolean))try{const r=await this.server.ldap.search({paged:!1,scope:"sub",filter:`(member=${n(e)})`,attributes:["dn"]},s);t.push(...r.searchEntries.map(e=>e.dn))}catch(e){if(32!==o(e))throw e}return t}async leaveGroups(e){for(const t of await this.groupsOf(e))await this.leaveGroup(t,e)}async leaveGroup(e,t){const s=this.config.group_dummy_user;let r=!1;for(let a=0;;a++)try{return void await this.server.ldap.modify(e,r?{add:{member:s},delete:{member:t}}:{delete:{member:t}})}catch(e){const t=o(e);if(16===t)return;if(a>=2)throw e;if(65===t&&s)r=!0;else{if(20!==t)throw e;r=!1}}}}export{TwakeTombstone as default};
@@ -4,6 +4,17 @@ import type ldapActions from '../lib/ldapActions';
4
4
  import type { AttributesList, AttributeValue, LdapList, ModifyRequest } from '../lib/ldapActions';
5
5
  import type { Schema } from '../config/schema';
6
6
  import DmPlugin from './plugin';
7
+ /** What {@link LdapFlat.listEntries} is asked for. */
8
+ export interface ListEntriesOptions {
9
+ /** LDAP filter, every entry of the branch when absent */
10
+ filter?: string;
11
+ /** attributes to return, all when absent */
12
+ attributes?: string[];
13
+ /** incoming request, forwarded to the authorization hooks */
14
+ req?: Request;
15
+ /** keep at most this many entries, all when absent */
16
+ limit?: number;
17
+ }
7
18
  export interface LdapFlatConfig {
8
19
  /**
9
20
  * LDAP branch where entries are stored
@@ -368,13 +379,30 @@ export default abstract class LdapFlat extends DmPlugin {
368
379
  */
369
380
  private getDepartmentPath;
370
381
  /**
371
- * List entries from LDAP
382
+ * List entries from LDAP.
383
+ *
384
+ * @returns the entries; {@link listEntriesLimited} also says whether
385
+ * `limit` left some out
372
386
  */
373
- listEntries({ filter, attributes, req, }: {
374
- filter?: string;
375
- attributes?: string[];
376
- req?: Request;
377
- }): Promise<LdapList>;
387
+ listEntries(options: ListEntriesOptions): Promise<LdapList>;
388
+ /**
389
+ * List entries, and say whether some were left out.
390
+ *
391
+ * What the answers are is in docs/usage/plugins/ldap/flat-generic.md
392
+ * ("Long lists"). What the code has to work around: a search carrying a
393
+ * `sizeLimit` is never refused by ldapts — it returns the entries it got
394
+ * and drops the result code, including when the server cut the answer at
395
+ * its own, lower, limit. A bounded search therefore cannot prove a list
396
+ * whole, and only serves as the fallback once the paged walk, which can,
397
+ * has been refused. Its answer is flagged as truncated whatever it holds.
398
+ *
399
+ * @returns the entries, keyed by main attribute, and whether any were left
400
+ * out
401
+ */
402
+ listEntriesLimited({ filter, attributes, req, limit, }: ListEntriesOptions): Promise<{
403
+ entries: LdapList;
404
+ truncated: boolean;
405
+ }>;
378
406
  searchEntriesByName(name: string, partial?: boolean, attrs?: string[]): Promise<LdapList>;
379
407
  validateNewEntry(dn: string, entry: AttributesList): Promise<boolean>;
380
408
  validateChanges(dn: string, changes: ModifyRequest): Promise<boolean>;
@@ -218,6 +218,17 @@ export interface Config {
218
218
  twake_lifecycle_deleted_at_attribute?: string;
219
219
  twake_lifecycle_deleted_at_format?: string;
220
220
  twake_lifecycle_rules?: string;
221
+ twake_lifecycle_member_attribute?: string;
222
+ twake_group_base?: string;
223
+ twake_group_user_base?: string;
224
+ twake_group_organization_dn?: string;
225
+ twake_group_organization_status_attribute?: string;
226
+ twake_group_organization_deleted_value?: string;
227
+ twake_group_display_name_attribute?: string;
228
+ twake_group_color_attribute?: string;
229
+ twake_group_created_at_attribute?: string;
230
+ twake_group_max_page_limit?: number;
231
+ twake_group_member_fields?: Record<string, string>;
221
232
  twake_instance_dn?: string[];
222
233
  twake_instance_skip_attribute?: string;
223
234
  twake_instance_skip_value?: string;
@@ -249,6 +260,7 @@ export interface Config {
249
260
  twake_instance_organization_base?: string;
250
261
  twake_instance_organization_id_attribute?: string;
251
262
  twake_instance_organization_domain_attribute?: string;
263
+ twake_instance_organization_name_attribute?: string;
252
264
  twake_instance_organization_fqdn_attribute?: string;
253
265
  twake_lifecycle_reason_attribute?: string;
254
266
  twake_tombstone_dn?: string[];
@@ -45,6 +45,9 @@ export declare class ConflictError extends HttpError {
45
45
  export declare class UriTooLongError extends HttpError {
46
46
  constructor(message?: string);
47
47
  }
48
+ export declare class UnprocessableEntityError extends HttpError {
49
+ constructor(message?: string);
50
+ }
48
51
  export declare class TooManyRequestsError extends HttpError {
49
52
  constructor(message?: string);
50
53
  }
@@ -63,6 +63,8 @@ export declare class ClouderyProvider extends HttpProvider implements InstancePr
63
63
  name: string;
64
64
  domain: string;
65
65
  }): Promise<void>;
66
+ /** The Cloudery takes a member's user.created once its organization is linked */
67
+ linkOrganization(id: string, fqdn: string): Promise<void>;
66
68
  }
67
69
  export declare class CozyStackProvider extends HttpProvider implements InstanceProvider {
68
70
  private readonly url;
@@ -84,6 +84,21 @@ export default class OpenIDConnect extends AuthBase {
84
84
  * @param next called once the request carries an identity
85
85
  */
86
86
  authMethod(req: DmRequest, res: Response, next: () => void): void;
87
+ /**
88
+ * Keep a session no longer than its access token: past its expiry, renewed
89
+ * with the refresh token when the provider gave one, ended otherwise, so
90
+ * the caller goes back through the provider, which asks again if its own
91
+ * session is over.
92
+ *
93
+ * The library leaves the cookie alive for its own duration, a day of
94
+ * inactivity and a week at most, whatever the provider said, and nothing
95
+ * here reads the access token otherwise. A token with no expiry is kept.
96
+ *
97
+ * @param req the request, after the library's router
98
+ * @returns the renewal under way, which never rejects: a refused or failed
99
+ * one ends the session. Undefined when there is nothing to wait for.
100
+ */
101
+ private renewExpired;
87
102
  /**
88
103
  * Whether `/logout` failed because the provider offers no RP-Initiated
89
104
  * Logout, rather than for a reason worth a 500.
@@ -86,6 +86,11 @@ export default class LdapGroups extends DmPlugin {
86
86
  cn: string;
87
87
  schema?: Schema;
88
88
  constructor(server: DM);
89
+ /**
90
+ * The loaded group schema, before it is used: a subclass declares there
91
+ * the attributes it writes.
92
+ */
93
+ protected adaptSchema(schema: Schema): Schema;
89
94
  /**
90
95
  * Remove deleted entries from groups once the delete has landed: a delete
91
96
  * refused, failed or turned into something else by another plugin leaves
@@ -1,7 +1,8 @@
1
1
  import type { ResourceMapping } from './types';
2
2
  export interface FilterOptions {
3
- /** LDAP attribute whose presence marks a User as locked. */
3
+ /** LDAP attribute and value that mark a User as locked. */
4
4
  lockAttribute?: string;
5
+ lockValue?: string;
5
6
  /**
6
7
  * Whether this resource type carries SCIM `active`. Users do; Groups do
7
8
  * not, and must get `invalidFilter` rather than a filter built out of a
@@ -36,6 +36,7 @@ export declare class ScimGroups {
36
36
  private readonly users;
37
37
  private readonly hooks;
38
38
  private readonly mapping;
39
+ private readonly attributes;
39
40
  private readonly rdnAttribute;
40
41
  private readonly objectClass;
41
42
  private readonly idAttribute;
@@ -47,9 +47,9 @@ export interface MappingContext {
47
47
  baseUrl?: string;
48
48
  scimPrefix: string;
49
49
  /**
50
- * LDAP attribute whose *presence* marks the account as locked, and the
51
- * value written to lock it. Both come from `resolveLockConfig()`, which is
52
- * the only place the defaults live: re-defaulting here would let a context
50
+ * LDAP attribute, and the value in it that marks the account as locked and
51
+ * is written to lock it. Both come from `resolveLockConfig()`, which is the
52
+ * only place the defaults live: re-defaulting here would let a context
53
53
  * built outside the plugin reach the directory with a pair the startup
54
54
  * guard never saw. Groups have no `active`, so they pass empty strings.
55
55
  */
@@ -58,8 +58,8 @@ export interface MappingContext {
58
58
  }
59
59
  /**
60
60
  * SCIM `active` (RFC 7643 section 4.1.1) has no direct LDAP equivalent. We
61
- * model it on the presence of one attribute: present means locked, absent
62
- * means active.
61
+ * model it on one attribute holding one value: that value means locked,
62
+ * anything else, absence included, means active.
63
63
  *
64
64
  * The default is the ppolicy overlay's `pwdAccountLockedTime`, whose
65
65
  * conventional "locked forever" value is a GeneralizedTime in the distant
@@ -122,8 +122,19 @@ export declare function resolveLockConfig(attribute: string, value: string, opti
122
122
  * translator cannot drift apart.
123
123
  */
124
124
  export declare function readActive(value: unknown): boolean | undefined;
125
- /** Is this entry locked, per the configured lock attribute? */
126
- export declare function isLocked(entry: AttributesList, ctx: MappingContext): boolean;
125
+ /**
126
+ * Is this entry locked, per the configured lock pair? Only the lock value
127
+ * counts: a ppolicy lockout after failed binds is a timestamp in the same
128
+ * attribute, and it does not deactivate the account.
129
+ */
130
+ export declare function isLocked(entry: AttributesList, ctx: Pick<MappingContext, 'lockAttribute' | 'lockValue'>): boolean;
131
+ /**
132
+ * Does the entry hold this value in this attribute? Both compared
133
+ * case-insensitively, whatever the attribute's matching rule, and an
134
+ * attribute given as an empty array is absent. SCIM `active` and the twake
135
+ * lifecycle plugins read the same lock pair through it.
136
+ */
137
+ export declare function holdsValue(entry: AttributesList, attribute: string, value: string): boolean;
127
138
  /** Resolve the SCIM id for an LDAP entry, per configuration. */
128
139
  export declare function resolveScimId(entry: AttributesList, ctx: MappingContext): string | undefined;
129
140
  export declare function buildLocation(id: string, ctx: MappingContext): string | undefined;
@@ -28,8 +28,8 @@ export interface PatchContext {
28
28
  /** The LDAP attribute holding members, default 'member'. */
29
29
  memberAttribute?: string;
30
30
  /**
31
- * For Users: the attribute whose presence marks the account as locked, and
32
- * the value to write when SCIM `active` is set to false.
31
+ * For Users: the attribute and value that mark the account as locked;
32
+ * SCIM `active: false` writes that value.
33
33
  */
34
34
  lockAttribute?: string;
35
35
  lockValue?: string;
@@ -0,0 +1,89 @@
1
+ import type { Express } from 'express';
2
+ import type { DM } from '../../bin';
3
+ import type { Schema } from '../../config/schema';
4
+ import type { Hooks } from '../../hooks';
5
+ import type { AttributeValue } from '../../lib/ldapActions';
6
+ import LdapGroups from '../ldap/groups';
7
+ import { type LifecycleAttributes } from './lifecycleAttributes';
8
+ /** The values of an attribute, whatever shape the directory gave them. */
9
+ export declare function values(value: AttributeValue | undefined): string[];
10
+ export default class TwakeGroups extends LdapGroups {
11
+ name: string;
12
+ protected readonly groupBase: string;
13
+ protected readonly userBase: string;
14
+ protected readonly groupPattern: RegExp;
15
+ protected readonly attrs: LifecycleAttributes;
16
+ private readonly orgPattern;
17
+ private readonly displayName;
18
+ private readonly color;
19
+ private readonly createdAt;
20
+ private readonly maxPage;
21
+ private readonly memberFields;
22
+ constructor(server: DM);
23
+ /**
24
+ * Declare the group attributes under their configured names, whatever case
25
+ * the schema spells them in, or add them when it does not: the schema does
26
+ * not have to follow a renamed attribute.
27
+ */
28
+ protected adaptSchema(schema: Schema): Schema;
29
+ /**
30
+ * No `ldapdeletedone`: an erased member leaves its groups through the
31
+ * directory's referential integrity, whatever made the delete. The cleanup
32
+ * inherited from core/ldap/groups writes through LDAP-Rest, so every group
33
+ * would announce a member removed on an erase, which publishes nothing.
34
+ */
35
+ hooks: Hooks;
36
+ /**
37
+ * The id a `scimgroupcreate` hook assigns names the entry only when the
38
+ * SCIM group mapping leaves the RDN attribute alone.
39
+ */
40
+ afterLoad(): void;
41
+ /** The organization a group DN belongs to, if it is a group at all. */
42
+ organizationOf(dn: string): string | undefined;
43
+ groupBaseOf(org: string): string;
44
+ userBaseOf(org: string): string;
45
+ /**
46
+ * A group's members belong to its organization, as a provider is trusted
47
+ * within its own organization only. The placeholder belongs to none.
48
+ */
49
+ private checkMembers;
50
+ /**
51
+ * A tombstone keeps its memberships until it is erased, and is hidden from
52
+ * them, so every API reads the same member list.
53
+ */
54
+ private hideTombstones;
55
+ private tombstonesOf;
56
+ /**
57
+ * Only the organization routes: the flat `/v1/ldap/groups` of
58
+ * core/ldap/groups would read one base that holds no group here.
59
+ */
60
+ api(app: Express): void;
61
+ private groupDn;
62
+ private get userAttribute();
63
+ /** Every route answers 404 for a missing organization, 410 for a deleted one. */
64
+ private checkOrganization;
65
+ /** Page, search and sort of a list route, or a refusal of them. */
66
+ private page;
67
+ private sorted;
68
+ /** A group as the routes answer it: members named by their RDN value. */
69
+ private groupOf;
70
+ private groups;
71
+ private readGroup;
72
+ /** The group of that display name in the organization, if any. */
73
+ private named;
74
+ /** A write to a group that is not there answers 404. */
75
+ private onGroup;
76
+ private listRoute;
77
+ private createRoute;
78
+ private updateRoute;
79
+ private membersRoute;
80
+ /**
81
+ * Users of the organization by name, read in pages of the size limit. A
82
+ * tombstone is no user: it cannot be added, as it would join hidden.
83
+ */
84
+ private users;
85
+ private profiles;
86
+ private addMembersRoute;
87
+ private dropPlaceholder;
88
+ private removeMemberRoute;
89
+ }