ldap-rest 0.25.0 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/lib/instanceProviders.js +1 -1
- package/dist/plugins/ldap/groups.js +1 -1
- package/dist/plugins/twake/instances.js +1 -1
- package/dist/src/lib/instanceProviders.d.ts +8 -0
- package/dist/src/plugins/ldap/groups.d.ts +12 -0
- package/docs/usage/plugins/integrations/instances.md +6 -3
- package/docs/usage/plugins/ldap/groups.md +6 -1
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
import t from"node-fetch";import{ConflictError as e,GatewayTimeoutError as i,BadGatewayError as a}from"./errors.js";const instanceSlug=t=>t.toLowerCase().replace(/[^a-z0-9-]/g,"").replace(/^-+|-+$/g,"");async function refused(t,e){const i=(await e.text().catch(()=>"")).slice(0,500);return new a(`${t}: HTTP ${e.status}${i?` ${i}`:""}`)}class HttpProvider{constructor(t,e){this.domain=t,this.timeout=e}address(t){return`${t.id}.${this.domain}`.toLowerCase()}async call(e,i={}){try{return await t(e,{...i,signal:AbortSignal.timeout(this.timeout)})}catch(t){throw this.timedOut(t,e)}}async json(t){try{return await t.json()}catch(e){throw this.timedOut(e,t.url)}}timedOut(t,e){return["AbortError","TimeoutError"].includes(t.name)?new i(`${new URL(e).host} did not answer within ${this.timeout} ms`):t}}class ClouderyProvider extends HttpProvider{constructor(t,e,i,a,s){super(i,s),this.url=t,this.token=e,this.offer=a}headers(){return{Authorization:`Bearer ${this.token}`,"Content-Type":"application/json"}}async create(t){const e=await this.call(`${this.url}/api/v1/instances`,{method:"POST",headers:this.headers(),body:JSON.stringify({email:t.email,internal_email:t.email,publicName:t.publicName,public_name:t.publicName,locale:t.locale,oidc:t.id,slug:t.id,phone:t.phone||"",offer:t.offer||this.offer,domain:this.domain,skip_email_validation:!0,...t.orgId?{org_id:t.orgId}:{},...t.orgDomain?{org_domain:t.orgDomain}:{}})});if(!e.ok)throw await refused("Cloudery refused the instance",e)}async find(t,i){const a=(i??this.address(t)).toLowerCase(),s=new URL(`${this.url}/api/v2/instances`);s.searchParams.set("fqdn",a),s.searchParams.set("limit","1");const o=await this.call(s.toString(),{headers:this.headers()});if(!o.ok)throw await refused("Cloudery instance search failed",o);const{items:r}=await this.json(o),n=r?.[0];if(n){if(n.internal_email&&n.internal_email.toLowerCase()!==t.email.toLowerCase()&&(!t.organization||!await this.organizationHolds(t.id,n._id))||n.oidc&&n.oidc!==t.id)throw new e(`${a} already exists and belongs to another user`);return{fqdn:n.fqdn,ready:Boolean(n.instantiated_at)}}}async organizationHolds(t,e){if(!e)return!1;const i=await this.call(`${this.url}/api/v2/organizations/${encodeURIComponent(t)}`,{headers:this.headers()});if(404===i.status)return!1;if(!i.ok)throw await refused("Cloudery organization lookup failed",i);return(await this.json(i)).instance_id===e}async createOrganization(t){const e=await this.call(`${this.url}/api/v2/organizations`,{method:"POST",headers:this.headers(),body:JSON.stringify({ldap_branch:t.id,name:t.name,custom_domain:t.domain})});if(!e.ok&&409!==e.status)throw await refused("Cloudery refused the organization",e)}async linkOrganization(t,e){const i=await this.call(`${this.url}/api/v2/organizations/${encodeURIComponent(t)}`,{method:"PATCH",headers:this.headers(),body:JSON.stringify({instance_fqdn:e})});if(!i.ok)throw await refused("Cloudery refused to link the organization",i)}}class CozyStackProvider extends HttpProvider{constructor(t,e,i,a,s,o,r){super(a,r),this.url=t,this.context=s,this.apps=o,this.auth=`Basic ${Buffer.from(`${e}:${i}`).toString("base64")}`}headers(){return{Authorization:this.auth,Accept:"application/json"}}async create(t){const i=this.address(t),a=new URLSearchParams({Domain:i,Email:t.email,PublicName:t.publicName,Locale:t.locale,OIDCID:t.oidc??t.id});t.phone&&a.set("Phone",t.phone),t.orgId&&a.set("OrgID",t.orgId),t.orgDomain&&a.set("OrgDomain",t.orgDomain),this.context&&a.set("ContextName",this.context),this.apps&&a.set("Apps",this.apps);const s=await this.call(`${this.url}/instances?${a.toString()}`,{method:"POST",headers:this.headers()});if(409===s.status){if(!await this.find(t))throw new e(`${i} already exists`);return i}if(!s.ok)throw await refused("cozy-stack refused the instance",s);return await this.finishOnboarding(i),i}async find(t,i){const a=(i??this.address(t)).toLowerCase(),s=await this.read(a);if(s){if(s.email?.toLowerCase()!==t.email.toLowerCase()||s.oidc_id!==(t.oidc??t.id))throw new e(`${a} already exists and belongs to another user`);return s.onboarding_finished||await this.finishOnboarding(a),{fqdn:a,ready:!0}}}async destroy(t,i){const a=this.address({id:t}),s=await this.read(a);if(!s)return;if(s.oidc_id!==i)throw new e(`${a} belongs to another user, left in place`);const o=await this.call(`${this.url}/instances/${encodeURIComponent(a)}`,{method:"DELETE",headers:this.headers()});if(!o.ok&&404!==o.status)throw await refused("cozy-stack refused to destroy the instance",o)}async read(t){const e=await this.call(`${this.url}/instances/${encodeURIComponent(t)}`,{headers:this.headers()});if(404===e.status)return;if(!e.ok)throw await refused("cozy-stack instance read failed",e);const{data:i}=await this.json(e);return i?.attributes??{}}async finishOnboarding(t){const e=await this.call(`${this.url}/instances/${encodeURIComponent(t)}?OnboardingFinished=true`,{method:"PATCH",headers:this.headers()});if(!e.ok)throw await refused("cozy-stack refused to finish onboarding",e)}}export{ClouderyProvider,CozyStackProvider,instanceSlug};
|
|
1
|
+
import t from"node-fetch";import{ConflictError as e,GatewayTimeoutError as i,BadGatewayError as a}from"./errors.js";const instanceSlug=t=>t.toLowerCase().replace(/[^a-z0-9-]/g,"").replace(/^-+|-+$/g,""),matrixId=t=>{const e=t.lastIndexOf("@"),i=t.slice(0,e),a=t.slice(e+1),s=`@${i}:${a}`.toLowerCase();if(!(e<1||!a||i.includes(":")||/\s/.test(s)||Buffer.byteLength(s)>255))return s};async function refused(t,e){const i=(await e.text().catch(()=>"")).slice(0,500);return new a(`${t}: HTTP ${e.status}${i?` ${i}`:""}`)}class HttpProvider{constructor(t,e){this.domain=t,this.timeout=e}address(t){return`${t.id}.${this.domain}`.toLowerCase()}async call(e,i={}){try{return await t(e,{...i,signal:AbortSignal.timeout(this.timeout)})}catch(t){throw this.timedOut(t,e)}}async json(t){try{return await t.json()}catch(e){throw this.timedOut(e,t.url)}}timedOut(t,e){return["AbortError","TimeoutError"].includes(t.name)?new i(`${new URL(e).host} did not answer within ${this.timeout} ms`):t}}class ClouderyProvider extends HttpProvider{constructor(t,e,i,a,s){super(i,s),this.url=t,this.token=e,this.offer=a}headers(){return{Authorization:`Bearer ${this.token}`,"Content-Type":"application/json"}}async create(t){const e=await this.call(`${this.url}/api/v1/instances`,{method:"POST",headers:this.headers(),body:JSON.stringify({email:t.email,internal_email:t.email,publicName:t.publicName,public_name:t.publicName,locale:t.locale,oidc:t.id,slug:t.id,phone:t.phone||"",offer:t.offer||this.offer,domain:this.domain,skip_email_validation:!0,...t.orgId?{org_id:t.orgId}:{},...t.orgDomain?{org_domain:t.orgDomain}:{}})});if(!e.ok)throw await refused("Cloudery refused the instance",e)}async find(t,i){const a=(i??this.address(t)).toLowerCase(),s=new URL(`${this.url}/api/v2/instances`);s.searchParams.set("fqdn",a),s.searchParams.set("limit","1");const o=await this.call(s.toString(),{headers:this.headers()});if(!o.ok)throw await refused("Cloudery instance search failed",o);const{items:r}=await this.json(o),n=r?.[0];if(n){if(n.internal_email&&n.internal_email.toLowerCase()!==t.email.toLowerCase()&&(!t.organization||!await this.organizationHolds(t.id,n._id))||n.oidc&&n.oidc!==t.id)throw new e(`${a} already exists and belongs to another user`);return{fqdn:n.fqdn,ready:Boolean(n.instantiated_at)}}}async organizationHolds(t,e){if(!e)return!1;const i=await this.call(`${this.url}/api/v2/organizations/${encodeURIComponent(t)}`,{headers:this.headers()});if(404===i.status)return!1;if(!i.ok)throw await refused("Cloudery organization lookup failed",i);return(await this.json(i)).instance_id===e}async createOrganization(t){const e=await this.call(`${this.url}/api/v2/organizations`,{method:"POST",headers:this.headers(),body:JSON.stringify({ldap_branch:t.id,name:t.name,custom_domain:t.domain})});if(!e.ok&&409!==e.status)throw await refused("Cloudery refused the organization",e)}async linkOrganization(t,e){const i=await this.call(`${this.url}/api/v2/organizations/${encodeURIComponent(t)}`,{method:"PATCH",headers:this.headers(),body:JSON.stringify({instance_fqdn:e})});if(!i.ok)throw await refused("Cloudery refused to link the organization",i)}}class CozyStackProvider extends HttpProvider{constructor(t,e,i,a,s,o,r){super(a,r),this.url=t,this.context=s,this.apps=o,this.auth=`Basic ${Buffer.from(`${e}:${i}`).toString("base64")}`}headers(){return{Authorization:this.auth,Accept:"application/json"}}async create(t){const i=this.address(t),a=new URLSearchParams({Domain:i,Email:t.email,PublicName:t.publicName,Locale:t.locale,OIDCID:t.oidc??t.id});t.phone&&a.set("Phone",t.phone),t.orgId&&a.set("OrgID",t.orgId),t.orgDomain&&a.set("OrgDomain",t.orgDomain),this.context&&a.set("ContextName",this.context),this.apps&&a.set("Apps",this.apps);const s=await this.call(`${this.url}/instances?${a.toString()}`,{method:"POST",headers:this.headers()});if(409===s.status){if(!await this.find(t))throw new e(`${i} already exists`);return i}if(!s.ok)throw await refused("cozy-stack refused the instance",s);return await this.finishOnboarding(i),i}async find(t,i){const a=(i??this.address(t)).toLowerCase(),s=await this.read(a);if(s){if(s.email?.toLowerCase()!==t.email.toLowerCase()||s.oidc_id!==(t.oidc??t.id))throw new e(`${a} already exists and belongs to another user`);return s.onboarding_finished||await this.finishOnboarding(a),{fqdn:a,ready:!0}}}async destroy(t,i){const a=this.address({id:t}),s=await this.read(a);if(!s)return;if(s.oidc_id!==i)throw new e(`${a} belongs to another user, left in place`);const o=await this.call(`${this.url}/instances/${encodeURIComponent(a)}`,{method:"DELETE",headers:this.headers()});if(!o.ok&&404!==o.status)throw await refused("cozy-stack refused to destroy the instance",o)}async read(t){const e=await this.call(`${this.url}/instances/${encodeURIComponent(t)}`,{headers:this.headers()});if(404===e.status)return;if(!e.ok)throw await refused("cozy-stack instance read failed",e);const{data:i}=await this.json(e);return i?.attributes??{}}async finishOnboarding(t){const e=await this.call(`${this.url}/instances/${encodeURIComponent(t)}?OnboardingFinished=true`,{method:"PATCH",headers:this.headers()});if(!e.ok)throw await refused("cozy-stack refused to finish onboarding",e)}}export{ClouderyProvider,CozyStackProvider,instanceSlug,matrixId};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import e from"../../abstract/plugin.js";import{assertModifyRequest as t}from"../../lib/ldapActions.js";import{wantJson as r,jsonBody as i,tryMethod as s,ok as a}from"../../lib/expressFormatedResponses.js";import{extractLdapCode as o}from"../../lib/ldapCodes.js";import{loadSchemaFileAsync as n,schemaUrl as h}from"../../lib/schemaFile.js";import{movedToTrash as c,asyncHandler as d,isDummyMemberDn as p,escapeDnValue as m,validateDnValue as u,launchHooksChained as l,launchHooks as f,escapeLdapFilter as g,organizationLink as w,isAuthzRefusal as b,substringSearchFilter as y}from"../../lib/utils.js";import{NotFoundError as $,BadRequestError as v,HttpError as _}from"../../lib/errors.js";import{assertClientMaySet as A,modifiedAttributeNames as G,toDirectoryEntry as q,invalidValueMessage as C,missingRequiredAttribute as E,toDirectoryChanges as j,checkDnValues as k,matchesPattern as M}from"../../config/schema.js";class LdapGroups extends e{constructor(e){if(super(e),this.name="ldapGroups",this.roles=["api","configurable"],this.hooks={ldapdeletedone:async e=>{const t=Array.isArray(e)?e:[e];this.logger.debug(`User deletion detected, removing from groups: ${t.join(", ")}`),await Promise.all(t.map(e=>this.deleteMemberFromAll(e))).catch(e=>{this.logger.error("Failed to process user deletion in groups:",e)})},ldaprenamedone:async([e,t])=>{c(this.config.trash_base,e,t)&&await this.deleteMemberFromAll(e).catch(e=>{this.logger.error("Failed to process a move to the trash in groups:",e)})}},this.ldap=e.ldap,this.base=this.config.ldap_group_base,this.cn=this.config.ldap_groups_main_attribute,this.base||(this.base=this.config.ldap_base,this.logger.warn(`LDAP group base is not defined, using "${this.base}"`)),!this.base)throw new Error("LDAP base is not defined, please set --ldap-group-base");if(this.config.ldap_group_base&&e.ldap.declareObjectClasses(this.base,this.config.group_class||[]),this.config.group_schema){const e=this.config.group_schema;n(e,{config:this.config}).then(e=>{this.schema=this.adaptSchema(e),this.logger.debug("Group schema loaded")}).catch(t=>this.logger.error(`Failed to load group schema from ${e}: ${t}`))}}adaptSchema(e){return e}api(e){e.get(`${this.config.api_prefix}/v1/ldap/groups`,d(async(e,t)=>{if(!r(e,t))return;const i={};if(e.query.match){if("string"!=typeof e.query.match)throw new v("Invalid match query");if("string"==typeof e.query.attribute&&e.query.attribute)i.filter=y(e.query.match,e.query.attribute);else if(/=/.test(e.query.match)){if(!/^[\w*=()&|, .@'-]+$/.test(e.query.match))throw new v("Invalid match query: contains forbidden characters");i.filter=e.query.match}else{const t=g(e.query.match);i.filter=`(${this.cn}=${t})`}}e.query.attributes&&"string"==typeof e.query.attributes&&(i.attributes=e.query.attributes.split(","));const s=await this.listGroups({...i,req:e});return a(t,this.hideNeverReturn(s))})),e.get(`${this.config.api_prefix}/v1/ldap/groups/:cn`,d(async(e,t)=>this.apiGet(e,t))),e.post(`${this.config.api_prefix}/v1/ldap/groups`,d(async(e,t)=>this.apiAdd(e,t,this.cn))),e.delete(`${this.config.api_prefix}/v1/ldap/groups/:cn`,d(async(e,t)=>this.apiDelete(e,t))),e.put(`${this.config.api_prefix}/v1/ldap/groups/:cn`,d(async(e,t)=>this.apiModify(e,t))),e.post(`${this.config.api_prefix}/v1/ldap/groups/:cn/members`,d(async(e,t)=>{const r=decodeURIComponent(e.params.cn);if(!r)throw new v("cn is required");const a=i(e,t,"member");if(!a)return;if((Array.isArray(a.member)?a.member:[a.member]).some(e=>"string"!=typeof e))throw new v("member must be a string or string array");await s(t,this.addMember.bind(this),r,a.member,e)})),e.delete(`${this.config.api_prefix}/v1/ldap/groups/:cn/members/:member`,d(async(e,t)=>{if(!r(e,t))return;const i=decodeURIComponent(e.params.cn),a=decodeURIComponent(e.params.member);if(!i||!a)throw new v("cn and member are required");await s(t,this.deleteMember.bind(this),i,a,e)})),this.config.ldap_organization_link_attribute&&this.config.ldap_organization_path_attribute&&e.post(`${this.config.api_prefix}/v1/ldap/groups/:cn/move`,d(async(e,t)=>{if(!r(e,t))return;const a=decodeURIComponent(e.params.cn);if(!a)throw new v("cn is required");const o=i(e,t,"targetOrgDn");if(o){if("string"!=typeof o.targetOrgDn)throw new v("targetOrgDn must be a string");await s(t,this.moveGroup.bind(this),a,o.targetOrgDn,e)}})),e.post(`${this.config.api_prefix}/v1/ldap/groups/:cn/rename`,d(async(e,t)=>{if(!r(e,t))return;const a=decodeURIComponent(e.params.cn);if(!a)throw new v("cn is required");const o=i(e,t,"newCn");if(o){if("string"!=typeof o.newCn)throw new v("newCn must be a string");await s(t,this.renameGroup.bind(this),a,o.newCn,e)}}))}isDummyMember(e){return p(e,this.config.group_dummy_user)}withoutDummyMember(e){const t=e.member;if(void 0===t)return e;const r=Array.isArray(t)?t:[t];return{...e,member:r.filter(e=>!this.isDummyMember(e))}}async apiGet(e,t){if(!r(e,t))return;const i=decodeURIComponent(e.params.cn);try{const r=/,/.test(i)?i:`${this.cn}=${m(i)},${this.base}`,s=await this.ldap.search({paged:!1,scope:"base"},r,e);if(0===s.searchEntries.length)throw new $("Group not found");t.json(this.hideNeverReturn(this.withoutDummyMember(s.searchEntries[0])))}catch(e){if(e.code&&32===e.code)throw new $("Group not found");throw e}}async apiAdd(e,t,...r){const a=i(e,t,...r);if(!a)return;A(this.schema,Object.keys(a),this.cn);const o=a[this.cn],n=a.member?a.member:[],h=Object.fromEntries(Object.entries(a).filter(([e,t])=>e!==this.cn&&"member"!==e&&void 0!==t&&!(!0===this.schema?.attributes[e]?.fixed)));await s(t,this.addGroup.bind(this),o,n,h,e)}async apiDelete(e,t){if(!r(e,t))return;const i=decodeURIComponent(e.params.cn);if(!i)throw new v("cn is required");await s(t,this.deleteGroup.bind(this),i,e)}async apiModify(e,r){const a=i(e,r);if(!a)return;t(a);const o=this.fixDn(decodeURIComponent(e.params.cn));if(!o)throw new v("cn is required");A(this.schema,G(a));const n=Object.fromEntries(Object.entries(a).filter(([e,t])=>!(!0===this.schema?.attributes[e]?.fixed)));await s(r,this.modifyGroup.bind(this),o,n,e)}async addGroup(e,t=[],r={},i){let s;new RegExp(`^${this.cn}=`).test(e)?(s=e,e=e.replace(new RegExp(`^${this.cn}=((?:\\\\.|[^,])+)(?:,.*)?$`),"$1")):(u(e,this.cn),s=`${this.cn}=${m(e)},${this.base}`),await this.validateMembers(s,t,i),await this.validateNewGroup(s,{objectClass:this.config.group_class,cn:e,member:t,...r});const a=[...this.config.group_class];!(r.mail||r.twakeMailboxType||r.twakeDepartmentLink||r.twakeDepartmentPath)||a.includes("twakeGroup")||a.includes("twakeStaticGroup")||a.push("twakeGroup");let o,n={objectClass:a,...this.config.group_default_attributes,[this.cn]:e,member:[this.config.group_dummy_user],...r};t.length&&n.member.push(...t),q(this.schema,n),[s,n]=await l(this.registeredHooks.ldapgroupadd,[s,n]);try{o=await this.ldap.add(s,n,i)}catch(e){if(e instanceof _)throw e;throw new Error(`Failed to add group ${s}: ${e}`)}return f(this.registeredHooks.ldapgroupadddone,[s,n]),o}async modifyGroup(e,t,r){let i=/,/.test(e)?e:`${this.cn}=${m(e)},${this.base}`;const s=this.opNumber();if([i,t]=await l(this.registeredHooks.ldapgroupmodify,[i,t,s]),t.add){if(t.add.member)throw new v("Use dedicated API to add members");if(t.add[this.cn])throw new v(`${this.cn} attribute is unique, cannot add`)}if(t.delete){if(t.delete instanceof Object){if(t.delete.member)throw new v("Use dedicated API to delete members");if(t.delete[this.cn])throw new v(`Cannot delete ${this.cn} attribute`)}if(Array.isArray(t.delete)){if(t.delete.includes("member"))throw new v("Use dedicated API to delete members");if(t.delete.includes(this.cn))throw new v(`Cannot delete ${this.cn} attribute`)}}if(t.replace){if(t.replace.member)throw new v("Use dedicated API to replace members");if(t.replace[this.cn])throw new v(`Use dedicated API to change ${this.cn} attribute`)}await this.validateChanges(i,t);const a=await this.ldap.modify(i,t,r);return f(this.registeredHooks.ldapgroupmodifydone,[i,t,s]),a}async renameGroup(e,t,r){/,/.test(e)||u(e,this.cn),/,/.test(t)||u(t,this.cn);let i=/,/.test(e)?e:`${this.cn}=${m(e)},${this.base}`,s=/,/.test(t)?t:`${this.cn}=${m(t)},${this.base}`;[i,s]=await l(this.registeredHooks.ldapgrouprename,[i,s]);const a=await this.ldap.rename(i,s,r);return f(this.registeredHooks.ldapgrouprenamedone,[i,s]),a}async deleteGroup(e,t){let r=/,/.test(e)?e:`${this.cn}=${m(e)},${this.base}`;r=await l(this.registeredHooks.ldapgroupdelete,r);const i=await this.ldap.delete(r,t);return f(this.registeredHooks.ldapgroupdeletedone,r),i}async addMember(e,t,r){const i=/,/.test(e)?e:`${this.cn}=${m(e)},${this.base}`;return Array.isArray(t)||(t=[t]),[e,t]=await l(this.registeredHooks.ldapgroupaddmember,[e,t]),await this.validateMembers(i,t,r),await this.ldap.modify(i,{add:{member:t}},r).catch(e=>{if(e instanceof _)throw e;throw new Error(`Failed to add member(s) to ${i}: ${e}`)})}async deleteMember(e,t,r){const i=/,/.test(e)?e:`${this.cn}=${m(e)},${this.base}`;if([e,t]=await l(this.registeredHooks.ldapgroupdeletemember,[e,t]),this.isDummyMember(t))throw new v("Cannot delete dummy member from group");return await this.ldap.modify(i,{delete:{member:t}},r).catch(e=>{if(16===o(e))throw new $(`${t} is not a member of ${i}`);if(e instanceof _)throw e;throw new Error(`Failed to delete member ${t} from ${i}: ${e}`)})}async deleteMemberFromAll(e){const t=await this.ldap.search({filter:`member=${g(e)}`,paged:!1,attributes:[this.cn]},this.base).catch(e=>{throw new Error(`Failed to search groups from ${this.base}: ${e}`)});await Promise.all(t.searchEntries.map(t=>this.ldap.modify(t.dn,{delete:{member:e}}).catch(r=>{if(65===o(r)&&this.config.group_dummy_user)return this.ldap.modify(t.dn,{add:{member:this.config.group_dummy_user},delete:{member:e}});throw r}).catch(r=>{const i=o(r);16!==i&&32!==i&&this.logger.error(`Failed to remove ${e} from group ${t.dn}:`,r)})))}async moveGroup(e,t,r){const i=this.config.ldap_organization_link_attribute,s=this.config.ldap_organization_path_attribute,a=/,/.test(e)?e:`${this.cn}=${m(e)},${this.base}`;let n;try{n=await this.ldap.search({paged:!1,scope:"base"},a)}catch(e){if(32===o(e))throw new $(`Group ${a} not found`);throw e}if(0===n.searchEntries.length)throw new $(`Group ${a} not found`);const h=n.searchEntries[0],c=w(h[i]);if(!c)throw new v(`Group ${a} does not have ${i} attribute and cannot be moved`);if(c===t)throw new v("Group is already in the target organization");let d;try{d=await this.ldap.search({paged:!1,scope:"base",attributes:[s,"ou","o"]},t)}catch(e){const r=o(e);if(32===r)throw new $(`Target organization ${t} not found`);if(34===r)throw new v(`${t} is not a valid DN`);throw new Error(`Target organization ${t} not found: ${e instanceof Error&&e.message?e.message:String(e)}`)}if(0===d.searchEntries.length)throw new $(`Target organization ${t} not found`);const p=d.searchEntries[0][s];if(!p)throw new Error(`Target organization ${t} does not have ${s} attribute`);return await this.ldap.modify(a,{replace:{[i]:t,[s]:p}},r),this.logger.info(`Group ${a} moved from ${c} to ${t}`),{success:!0}}async listGroups({filter:e,attributes:t,req:r}={}){const i=await this.ldap.search({filter:e||"(objectClass=*)",attributes:t||[this.cn,"member"],paged:!0},this.base,r).catch(e=>{throw new Error(`Failed to list groups from ${this.base}: ${e}`)});let s={};for await(const e of i)for(const t of e.searchEntries){const e=t[this.cn];if(Array.isArray(e)&&0===e.length)continue;e&&(s[e]=this.withoutDummyMember(t))}return s=await l(this.registeredHooks._ldapgrouplist,s),s}async searchGroupsByName(e,t=!1,r=[this.cn,"member"],i){const s=g(e),a=t?`(${this.cn}=*${s}*)`:`(${this.cn}=${s})`;return await this.listGroups({filter:a,attributes:r,req:i})}fixDn(e){return!!e&&(/,/.test(e)?e:`${this.cn}=${m(e)},${this.base}`)}async validateMembers(e,t,r){[e,t]=await l(this.server.hooks.ldapgroupvalidatemembers,[e,t]);const i=Boolean(this.config.groups_allow_unexistent_members);if((!i||r)&&t&&t.length)try{await Promise.all(t.map(e=>this.server.ldap.queryLimit(async()=>{let t;try{t=await this.ldap.search({paged:!1,scope:"base"},e,r)}catch(t){if(b(t))throw t;if(i)return;const r=o(t);if(32===r)throw new v(`Member ${e} not found`);if(34===r)throw new v(`Member ${e} is not a valid DN`);throw new Error(`Member ${e} not found: ${t}`)}if(r&&!t.searchEntries?.length)throw new v(`Member ${e} not found`)})))}catch(e){if(e instanceof _||b(e))throw e;throw new Error(`Failed to find member(s): ${e}`)}}async validateNewGroup(e,t){if(!this.schema)return q(void 0,t),!0;[e,t]=await l(this.server.hooks.ldapgroupvalidatenew,[e,t]);for(const[e,r]of Object.entries(t))if(!await this._validateOneChange(e,r))throw new v(C(e,this.schema.attributes[e]));const r=E(this.schema,t,this.server.loadedPlugins);if(r)throw new v(`Attribute "${r}" is required`);return q(this.schema,t),!0}async validateChanges(e,t){if(!this.schema)return j(void 0,t),!0;if([e,t]=await l(this.server.hooks.ldapgroupvalidatechanges,[e,t]),t.add)for(const[e,r]of Object.entries(t.add))if(!await this._validateOneChange(e,r))throw new v(C(e,this.schema.attributes[e]));if(t.replace)for(const[e,r]of Object.entries(t.replace))if(!await this._validateOneChange(e,r))throw new v(C(e,this.schema.attributes[e]));if(t.delete&&t.delete instanceof Object)for(const e of Array.isArray(t.delete)?t.delete:Object.keys(t.delete))await this._validateOneChange(e,null);return j(this.schema,t),!0}async _validateOneChange(e,t){if(!this.schema)return!0;const r=this.schema.attributes[e];if(!r){if(this.schema.strict)throw new v(`Attribute "${e}" is not allowed`);return!0}if(null==t||Array.isArray(t)&&0===t.length){if(r.required)throw new v(`Attribute "${e}" is required`);return!0}if("array"===r.type){if(!Array.isArray(t))throw new v(`Attribute "${e}" must be an array`);if(!r.items)throw new Error(`Schema error: no item for array ${e}`);if("array"===r.items.type)throw new Error(`Schema error: array of array not supported for ${e}`);if(r.items.test&&"pointer"!==r.items.type)for(const i of t)if(typeof i!==r.items.type)throw new v(`Attribute "${e}" must be of type ${r.items.type}`)}else if("pointer"!==r.type&&typeof t!==r.type)return!1;if(await k(e,r,t,async e=>(await this.ldap.search({paged:!1,scope:"base",attributes:["dn"]},e)).searchEntries.length>0),!M(r,t))throw new v(C(e,r));return!0}getConfigApiData(){const e=this.config.api_prefix||"/api",t=this.server.loadedPlugins.static&&this.config.group_schema?h(this.config,this.config.group_schema):void 0;return{enabled:!0,base:this.base||"",mainAttribute:this.cn||"cn",objectClass:this.config.group_class||["top","groupOfNames"],schema:this.schema,schemaUrl:t,endpoints:{list:`${e}/v1/ldap/groups`,get:`${e}/v1/ldap/groups/:id`,create:`${e}/v1/ldap/groups`,update:`${e}/v1/ldap/groups/:id`,delete:`${e}/v1/ldap/groups/:id`,addMember:`${e}/v1/ldap/groups/:id/members`,removeMember:`${e}/v1/ldap/groups/:id/members/:memberId`,rename:`${e}/v1/ldap/groups/:id/rename`,move:`${e}/v1/ldap/groups/:id/move`}}}}export{LdapGroups as default};
|
|
1
|
+
import e from"../../abstract/plugin.js";import{assertModifyRequest as t}from"../../lib/ldapActions.js";import{wantJson as r,jsonBody as i,tryMethod as s,ok as a}from"../../lib/expressFormatedResponses.js";import{extractLdapCode as o}from"../../lib/ldapCodes.js";import{loadSchemaFileAsync as n,schemaUrl as h}from"../../lib/schemaFile.js";import{movedToTrash as c,asyncHandler as d,isDummyMemberDn as m,escapeDnValue as p,validateDnValue as u,launchHooksChained as l,launchHooks as f,escapeLdapFilter as g,organizationLink as w,isAuthzRefusal as b,substringSearchFilter as y}from"../../lib/utils.js";import{NotFoundError as $,BadRequestError as v,HttpError as _,ConflictError as A}from"../../lib/errors.js";import{assertClientMaySet as G,modifiedAttributeNames as C,toDirectoryEntry as q,invalidValueMessage as M,missingRequiredAttribute as E,toDirectoryChanges as j,checkDnValues as k,matchesPattern as O}from"../../config/schema.js";class LdapGroups extends e{constructor(e){if(super(e),this.name="ldapGroups",this.roles=["api","configurable"],this.hooks={ldapdeletedone:async e=>{const t=Array.isArray(e)?e:[e];this.logger.debug(`User deletion detected, removing from groups: ${t.join(", ")}`),await Promise.all(t.map(e=>this.deleteMemberFromAll(e))).catch(e=>{this.logger.error("Failed to process user deletion in groups:",e)})},ldaprenamedone:async([e,t])=>{c(this.config.trash_base,e,t)&&await this.deleteMemberFromAll(e).catch(e=>{this.logger.error("Failed to process a move to the trash in groups:",e)})}},this.ldap=e.ldap,this.base=this.config.ldap_group_base,this.cn=this.config.ldap_groups_main_attribute,this.base||(this.base=this.config.ldap_base,this.logger.warn(`LDAP group base is not defined, using "${this.base}"`)),!this.base)throw new Error("LDAP base is not defined, please set --ldap-group-base");if(this.config.ldap_group_base&&e.ldap.declareObjectClasses(this.base,this.config.group_class||[]),this.config.group_schema){const e=this.config.group_schema;n(e,{config:this.config}).then(e=>{this.schema=this.adaptSchema(e),this.logger.debug("Group schema loaded")}).catch(t=>this.logger.error(`Failed to load group schema from ${e}: ${t}`))}}adaptSchema(e){return e}api(e){e.get(`${this.config.api_prefix}/v1/ldap/groups`,d(async(e,t)=>{if(!r(e,t))return;const i={};if(e.query.match){if("string"!=typeof e.query.match)throw new v("Invalid match query");if("string"==typeof e.query.attribute&&e.query.attribute)i.filter=y(e.query.match,e.query.attribute);else if(/=/.test(e.query.match)){if(!/^[\w*=()&|, .@'-]+$/.test(e.query.match))throw new v("Invalid match query: contains forbidden characters");i.filter=e.query.match}else{const t=g(e.query.match);i.filter=`(${this.cn}=${t})`}}e.query.attributes&&"string"==typeof e.query.attributes&&(i.attributes=e.query.attributes.split(","));const s=await this.listGroups({...i,req:e});return a(t,this.hideNeverReturn(s))})),e.get(`${this.config.api_prefix}/v1/ldap/groups/:cn`,d(async(e,t)=>this.apiGet(e,t))),e.post(`${this.config.api_prefix}/v1/ldap/groups`,d(async(e,t)=>this.apiAdd(e,t,this.cn))),e.delete(`${this.config.api_prefix}/v1/ldap/groups/:cn`,d(async(e,t)=>this.apiDelete(e,t))),e.put(`${this.config.api_prefix}/v1/ldap/groups/:cn`,d(async(e,t)=>this.apiModify(e,t))),e.post(`${this.config.api_prefix}/v1/ldap/groups/:cn/members`,d(async(e,t)=>{const r=decodeURIComponent(e.params.cn);if(!r)throw new v("cn is required");const a=i(e,t,"member");if(!a)return;if((Array.isArray(a.member)?a.member:[a.member]).some(e=>"string"!=typeof e))throw new v("member must be a string or string array");await s(t,this.addMember.bind(this),r,a.member,e)})),e.delete(`${this.config.api_prefix}/v1/ldap/groups/:cn/members/:member`,d(async(e,t)=>{if(!r(e,t))return;const i=decodeURIComponent(e.params.cn),a=decodeURIComponent(e.params.member);if(!i||!a)throw new v("cn and member are required");await s(t,this.deleteMember.bind(this),i,a,e)})),this.config.ldap_organization_link_attribute&&this.config.ldap_organization_path_attribute&&e.post(`${this.config.api_prefix}/v1/ldap/groups/:cn/move`,d(async(e,t)=>{if(!r(e,t))return;const a=decodeURIComponent(e.params.cn);if(!a)throw new v("cn is required");const o=i(e,t,"targetOrgDn");if(o){if("string"!=typeof o.targetOrgDn)throw new v("targetOrgDn must be a string");await s(t,this.moveGroup.bind(this),a,o.targetOrgDn,e)}})),e.post(`${this.config.api_prefix}/v1/ldap/groups/:cn/rename`,d(async(e,t)=>{if(!r(e,t))return;const a=decodeURIComponent(e.params.cn);if(!a)throw new v("cn is required");const o=i(e,t,"newCn");if(o){if("string"!=typeof o.newCn)throw new v("newCn must be a string");await s(t,this.renameGroup.bind(this),a,o.newCn,e)}}))}isDummyMember(e){return m(e,this.config.group_dummy_user)}withoutDummyMember(e){const t=e.member;if(void 0===t)return e;const r=Array.isArray(t)?t:[t];return{...e,member:r.filter(e=>!this.isDummyMember(e))}}async apiGet(e,t){if(!r(e,t))return;const i=decodeURIComponent(e.params.cn);try{const r=/,/.test(i)?i:`${this.cn}=${p(i)},${this.base}`,s=await this.ldap.search({paged:!1,scope:"base"},r,e);if(0===s.searchEntries.length)throw new $("Group not found");t.json(this.hideNeverReturn(this.withoutDummyMember(s.searchEntries[0])))}catch(e){if(e.code&&32===e.code)throw new $("Group not found");throw e}}async apiAdd(e,t,...r){const a=i(e,t,...r);if(!a)return;G(this.schema,Object.keys(a),this.cn);const o=a[this.cn],n=a.member?a.member:[],h=Object.fromEntries(Object.entries(a).filter(([e,t])=>e!==this.cn&&"member"!==e&&void 0!==t&&!(!0===this.schema?.attributes[e]?.fixed)));await s(t,this.addGroup.bind(this),o,n,h,e)}async apiDelete(e,t){if(!r(e,t))return;const i=decodeURIComponent(e.params.cn);if(!i)throw new v("cn is required");await s(t,this.deleteGroup.bind(this),i,e)}async apiModify(e,r){const a=i(e,r);if(!a)return;t(a);const o=this.fixDn(decodeURIComponent(e.params.cn));if(!o)throw new v("cn is required");G(this.schema,C(a));const n=Object.fromEntries(Object.entries(a).filter(([e,t])=>!(!0===this.schema?.attributes[e]?.fixed)));await s(r,this.modifyGroup.bind(this),o,n,e)}async addGroup(e,t=[],r={},i){let s;new RegExp(`^${this.cn}=`).test(e)?(s=e,e=e.replace(new RegExp(`^${this.cn}=((?:\\\\.|[^,])+)(?:,.*)?$`),"$1")):(u(e,this.cn),s=`${this.cn}=${p(e)},${this.base}`),await this.validateMembers(s,t,i),await this.validateNewGroup(s,{objectClass:this.config.group_class,cn:e,member:t,...r});const a=[...this.config.group_class];!(r.mail||r.twakeMailboxType||r.twakeDepartmentLink||r.twakeDepartmentPath)||a.includes("twakeGroup")||a.includes("twakeStaticGroup")||a.push("twakeGroup");let o,n={objectClass:a,...this.config.group_default_attributes,[this.cn]:e,member:[this.config.group_dummy_user],...r};t.length&&n.member.push(...t),q(this.schema,n),[s,n]=await l(this.registeredHooks.ldapgroupadd,[s,n]);try{o=await this.ldap.add(s,n,i)}catch(e){if(e instanceof _)throw e;throw new Error(`Failed to add group ${s}: ${e}`)}return f(this.registeredHooks.ldapgroupadddone,[s,n]),o}async modifyGroup(e,t,r){let i=/,/.test(e)?e:`${this.cn}=${p(e)},${this.base}`;const s=this.opNumber();if([i,t]=await l(this.registeredHooks.ldapgroupmodify,[i,t,s]),t.add){if(t.add.member)throw new v("Use dedicated API to add members");if(t.add[this.cn])throw new v(`${this.cn} attribute is unique, cannot add`)}if(t.delete){if(t.delete instanceof Object){if(t.delete.member)throw new v("Use dedicated API to delete members");if(t.delete[this.cn])throw new v(`Cannot delete ${this.cn} attribute`)}if(Array.isArray(t.delete)){if(t.delete.includes("member"))throw new v("Use dedicated API to delete members");if(t.delete.includes(this.cn))throw new v(`Cannot delete ${this.cn} attribute`)}}if(t.replace){if(t.replace.member)throw new v("Use dedicated API to replace members");if(t.replace[this.cn])throw new v(`Use dedicated API to change ${this.cn} attribute`)}await this.validateChanges(i,t);const a=await this.ldap.modify(i,t,r);return f(this.registeredHooks.ldapgroupmodifydone,[i,t,s]),a}async renameGroup(e,t,r){/,/.test(e)||u(e,this.cn),/,/.test(t)||u(t,this.cn);let i=/,/.test(e)?e:`${this.cn}=${p(e)},${this.base}`,s=/,/.test(t)?t:`${this.cn}=${p(t)},${this.base}`;[i,s]=await l(this.registeredHooks.ldapgrouprename,[i,s]);const a=await this.ldap.rename(i,s,r);return f(this.registeredHooks.ldapgrouprenamedone,[i,s]),a}async deleteGroup(e,t){let r=/,/.test(e)?e:`${this.cn}=${p(e)},${this.base}`;r=await l(this.registeredHooks.ldapgroupdelete,r);const i=await this.ldap.delete(r,t);return f(this.registeredHooks.ldapgroupdeletedone,r),i}async addMember(e,t,r){const i=/,/.test(e)?e:`${this.cn}=${p(e)},${this.base}`;return Array.isArray(t)||(t=[t]),[e,t]=await l(this.registeredHooks.ldapgroupaddmember,[e,t]),await this.validateMembers(i,t,r),await this.ldap.modify(i,{add:{member:t}},r).catch(e=>{if(e instanceof _)throw e;throw new Error(`Failed to add member(s) to ${i}: ${e}`)})}async deleteMember(e,t,r){const i=/,/.test(e)?e:`${this.cn}=${p(e)},${this.base}`;if([e,t]=await l(this.registeredHooks.ldapgroupdeletemember,[e,t]),this.isDummyMember(t))throw new v("Cannot delete dummy member from group");return await this.removeMember(i,t,r).catch(e=>{if(16===o(e))throw new $(`${t} is not a member of ${i}`);if(e instanceof _)throw e;throw new Error(`Failed to delete member ${t} from ${i}: ${e}`)})}async removeMember(e,t,r){return await this.ldap.modify(e,{delete:{member:t}},r).catch(i=>{if(65!==o(i))throw i;const s=this.config.group_dummy_user;if(!s)throw new A(`Cannot remove ${t}, the last member of ${e}: a group needs at least one member`);return this.ldap.modify(e,{add:{member:s},delete:{member:t}},r)})}async deleteMemberFromAll(e){const t=await this.ldap.search({filter:`member=${g(e)}`,paged:!1,attributes:[this.cn]},this.base).catch(e=>{throw new Error(`Failed to search groups from ${this.base}: ${e}`)});await Promise.all(t.searchEntries.map(t=>this.removeMember(t.dn,e).catch(r=>{const i=o(r);16!==i&&32!==i&&this.logger.error(`Failed to remove ${e} from group ${t.dn}:`,r)})))}async moveGroup(e,t,r){const i=this.config.ldap_organization_link_attribute,s=this.config.ldap_organization_path_attribute,a=/,/.test(e)?e:`${this.cn}=${p(e)},${this.base}`;let n;try{n=await this.ldap.search({paged:!1,scope:"base"},a)}catch(e){if(32===o(e))throw new $(`Group ${a} not found`);throw e}if(0===n.searchEntries.length)throw new $(`Group ${a} not found`);const h=n.searchEntries[0],c=w(h[i]);if(!c)throw new v(`Group ${a} does not have ${i} attribute and cannot be moved`);if(c===t)throw new v("Group is already in the target organization");let d;try{d=await this.ldap.search({paged:!1,scope:"base",attributes:[s,"ou","o"]},t)}catch(e){const r=o(e);if(32===r)throw new $(`Target organization ${t} not found`);if(34===r)throw new v(`${t} is not a valid DN`);throw new Error(`Target organization ${t} not found: ${e instanceof Error&&e.message?e.message:String(e)}`)}if(0===d.searchEntries.length)throw new $(`Target organization ${t} not found`);const m=d.searchEntries[0][s];if(!m)throw new Error(`Target organization ${t} does not have ${s} attribute`);return await this.ldap.modify(a,{replace:{[i]:t,[s]:m}},r),this.logger.info(`Group ${a} moved from ${c} to ${t}`),{success:!0}}async listGroups({filter:e,attributes:t,req:r}={}){const i=await this.ldap.search({filter:e||"(objectClass=*)",attributes:t||[this.cn,"member"],paged:!0},this.base,r).catch(e=>{throw new Error(`Failed to list groups from ${this.base}: ${e}`)});let s={};for await(const e of i)for(const t of e.searchEntries){const e=t[this.cn];if(Array.isArray(e)&&0===e.length)continue;e&&(s[e]=this.withoutDummyMember(t))}return s=await l(this.registeredHooks._ldapgrouplist,s),s}async searchGroupsByName(e,t=!1,r=[this.cn,"member"],i){const s=g(e),a=t?`(${this.cn}=*${s}*)`:`(${this.cn}=${s})`;return await this.listGroups({filter:a,attributes:r,req:i})}fixDn(e){return!!e&&(/,/.test(e)?e:`${this.cn}=${p(e)},${this.base}`)}async validateMembers(e,t,r){[e,t]=await l(this.server.hooks.ldapgroupvalidatemembers,[e,t]);const i=Boolean(this.config.groups_allow_unexistent_members);if((!i||r)&&t&&t.length)try{await Promise.all(t.map(e=>this.server.ldap.queryLimit(async()=>{let t;try{t=await this.ldap.search({paged:!1,scope:"base"},e,r)}catch(t){if(b(t))throw t;if(i)return;const r=o(t);if(32===r)throw new v(`Member ${e} not found`);if(34===r)throw new v(`Member ${e} is not a valid DN`);throw new Error(`Member ${e} not found: ${t}`)}if(r&&!t.searchEntries?.length)throw new v(`Member ${e} not found`)})))}catch(e){if(e instanceof _||b(e))throw e;throw new Error(`Failed to find member(s): ${e}`)}}async validateNewGroup(e,t){if(!this.schema)return q(void 0,t),!0;[e,t]=await l(this.server.hooks.ldapgroupvalidatenew,[e,t]);for(const[e,r]of Object.entries(t))if(!await this._validateOneChange(e,r))throw new v(M(e,this.schema.attributes[e]));const r=E(this.schema,t,this.server.loadedPlugins);if(r)throw new v(`Attribute "${r}" is required`);return q(this.schema,t),!0}async validateChanges(e,t){if(!this.schema)return j(void 0,t),!0;if([e,t]=await l(this.server.hooks.ldapgroupvalidatechanges,[e,t]),t.add)for(const[e,r]of Object.entries(t.add))if(!await this._validateOneChange(e,r))throw new v(M(e,this.schema.attributes[e]));if(t.replace)for(const[e,r]of Object.entries(t.replace))if(!await this._validateOneChange(e,r))throw new v(M(e,this.schema.attributes[e]));if(t.delete&&t.delete instanceof Object)for(const e of Array.isArray(t.delete)?t.delete:Object.keys(t.delete))await this._validateOneChange(e,null);return j(this.schema,t),!0}async _validateOneChange(e,t){if(!this.schema)return!0;const r=this.schema.attributes[e];if(!r){if(this.schema.strict)throw new v(`Attribute "${e}" is not allowed`);return!0}if(null==t||Array.isArray(t)&&0===t.length){if(r.required)throw new v(`Attribute "${e}" is required`);return!0}if("array"===r.type){if(!Array.isArray(t))throw new v(`Attribute "${e}" must be an array`);if(!r.items)throw new Error(`Schema error: no item for array ${e}`);if("array"===r.items.type)throw new Error(`Schema error: array of array not supported for ${e}`);if(r.items.test&&"pointer"!==r.items.type)for(const i of t)if(typeof i!==r.items.type)throw new v(`Attribute "${e}" must be of type ${r.items.type}`)}else if("pointer"!==r.type&&typeof t!==r.type)return!1;if(await k(e,r,t,async e=>(await this.ldap.search({paged:!1,scope:"base",attributes:["dn"]},e)).searchEntries.length>0),!O(r,t))throw new v(M(e,r));return!0}getConfigApiData(){const e=this.config.api_prefix||"/api",t=this.server.loadedPlugins.static&&this.config.group_schema?h(this.config,this.config.group_schema):void 0;return{enabled:!0,base:this.base||"",mainAttribute:this.cn||"cn",objectClass:this.config.group_class||["top","groupOfNames"],schema:this.schema,schemaUrl:t,endpoints:{list:`${e}/v1/ldap/groups`,get:`${e}/v1/ldap/groups/:id`,create:`${e}/v1/ldap/groups`,update:`${e}/v1/ldap/groups/:id`,delete:`${e}/v1/ldap/groups/:id`,addMember:`${e}/v1/ldap/groups/:id/members`,removeMember:`${e}/v1/ldap/groups/:id/members/:memberId`,rename:`${e}/v1/ldap/groups/:id/rename`,move:`${e}/v1/ldap/groups/:id/move`}}}}export{LdapGroups as default};
|
|
@@ -1 +1 @@
|
|
|
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,instanceSlug as s}from"../../lib/instanceProviders.js";import{extractLdapCode as o}from"../../lib/ldapCodes.js";import{rdnValue as c,asyncHandler as h,escapeLdapFilter as d}from"../../lib/utils.js";import{lifecycleAttributes as l,isTombstone as u}from"./lifecycleAttributes.js";const first=t=>{const i=Array.isArray(t)?t[0]:t;return(null==i?"":i.toString())||void 0},g=/(^|\/)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.offers=[],this.queues=new Map,this.announcing=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)})}}),e.orgId&&await this.announcing.get(e.orgId.toLowerCase()))},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)?c(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=>g.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=l(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`);const t=(i.twake_instance_cloudery_offer||"").split(";").map(t=>t.trim());if(t.length>1&&""===t[t.length-1]&&t.pop(),t.length>1&&t.length>this.patterns.length)throw new Error(`${this.name}: --twake-instance-cloudery-offer has more offers than --twake-instance-dn has expressions`);t.length>1&&(this.offers=t),this.provider=new r(i.twake_instance_cloudery_url.replace(/\/$/,""),i.twake_instance_cloudery_token||"",i.twake_instance_cloudery_domain,t.length>1?"":t[0],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`,h(async(t,n)=>{const r=a(t,n);if(!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){let n;const r=await this.serialized(t,async()=>{const r=await this.read(t,a);if(!r)throw new i(`No entry at ${t}`);const s=this.account(t,r);if(!s)throw new e(`${t} gets no instance`);if(n=s.orgId,first(this.value(r,this.sentAttribute)))return"ready";a&&await this.server.ldap.modify(t,{replace:{[this.sentAttribute]:[]}},a);const o=first(this.value(r,this.fqdnAttribute));if(o){const i=o.toLowerCase()===this.provider.address(s)?{fqdn:o,ready:!0}:await this.provider.find(s,o);if(i?.ready)return await this.ready(t,r,s,i.fqdn,a),"ready"}return this.provide(t,r,s,a)});return n&&await this.announcing.get(n.toLowerCase()),r}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,organization:!0}}async requestOrganization(t,i){if(!(this.provider instanceof r))return;const e=await this.findOrganization(t);if(!e||first(this.value(e,this.sentAttribute)))return;const a=first(this.value(e,this.organizationName)),n=first(this.value(e,this.organizationDomain));if(!a||!n)return void this.logger.warn(`${this.name}: organization ${t} has no ${this.organizationName} or ${this.organizationDomain}, so its instance cannot be asked for`);const s=this.organizationRequest({id:t,name:a,domain:n}),o=await this.provider.find(s);if(o){const r=t.toLowerCase();if(!o.ready||this.announcing.has(r))return;return i&&await this.server.ldap.modify(e.dn,{},i),void(this.announcing.has(r)||this.announcing.set(r,this.ensureOrganization({id:t,name:a,domain:n}).catch(i=>{this.logger.error({plugin:this.name,event:"organization",id:t,error:String(i)})}).finally(()=>this.announcing.delete(r))))}await this.provider.createOrganization({id:t,name:a,domain:n});try{await this.provider.create(s)}catch(t){if(!await this.provider.find(s))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,a);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){const s={declaredClasses:this.config.user_class};if(first(this.value(i,this.fqdnAttribute))!==a&&await this.server.ldap.modify(t,{replace:{[this.fqdnAttribute]:a}},n,s),first(this.value(i,this.sentAttribute)))return;const o=this.provider instanceof r&&e.orgId&&await this.findOrganization(e.orgId);if(o&&!first(this.value(o,this.sentAttribute))){if(t.toLowerCase().endsWith(`,${o.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,s)}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}=${d(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=o(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,d=this.offers[this.patterns.findIndex(i=>i.test(t))],l=this.id(s,e);if(l)return{id:l,...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}:{},...d?{offer:d}:{}};this.logger.warn(`${this.name}: ${t} gives no instance slug, no instance`)}match(t){return this.patterns.map(i=>i.exec(t)).find(t=>null!==t)}id(t,i){const e={uid:t,...i.groups};return s((this.config.twake_instance_id||"{uid}").replace(/\{(\w+)\}/g,(t,i)=>e[i]??""))}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=${d(t)})`},this.config.ldap_base,i)).searchEntries.find(t=>this.patterns.some(i=>i.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,matrixId as s,instanceSlug as o}from"../../lib/instanceProviders.js";import{extractLdapCode as c}from"../../lib/ldapCodes.js";import{rdnValue as h,asyncHandler as d,escapeLdapFilter as l}from"../../lib/utils.js";import{lifecycleAttributes as u,isTombstone as g}from"./lifecycleAttributes.js";const first=t=>{const i=Array.isArray(t)?t[0]:t;return(null==i?"":i.toString())||void 0},w=/(^|\/)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.offers=[],this.queues=new Map,this.announcing=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)})}}),e.orgId&&await this.announcing.get(e.orgId.toLowerCase()))},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)?h(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=>w.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=u(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`);const t=(i.twake_instance_cloudery_offer||"").split(";").map(t=>t.trim());if(t.length>1&&""===t[t.length-1]&&t.pop(),t.length>1&&t.length>this.patterns.length)throw new Error(`${this.name}: --twake-instance-cloudery-offer has more offers than --twake-instance-dn has expressions`);t.length>1&&(this.offers=t),this.provider=new r(i.twake_instance_cloudery_url.replace(/\/$/,""),i.twake_instance_cloudery_token||"",i.twake_instance_cloudery_domain,t.length>1?"":t[0],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`,d(async(t,n)=>{const r=a(t,n);if(!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){let n;const r=await this.serialized(t,async()=>{const r=await this.read(t,a);if(!r)throw new i(`No entry at ${t}`);const s=this.account(t,r);if(!s)throw new e(`${t} gets no instance`);if(n=s.orgId,first(this.value(r,this.sentAttribute)))return"ready";a&&await this.server.ldap.modify(t,{replace:{[this.sentAttribute]:[]}},a);const o=first(this.value(r,this.fqdnAttribute));if(o){const i=o.toLowerCase()===this.provider.address(s)?{fqdn:o,ready:!0}:await this.provider.find(s,o);if(i?.ready)return await this.ready(t,r,s,i.fqdn,a),"ready"}return this.provide(t,r,s,a)});return n&&await this.announcing.get(n.toLowerCase()),r}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,organization:!0}}async requestOrganization(t,i){if(!(this.provider instanceof r))return;const e=await this.findOrganization(t);if(!e||first(this.value(e,this.sentAttribute)))return;const a=first(this.value(e,this.organizationName)),n=first(this.value(e,this.organizationDomain));if(!a||!n)return void this.logger.warn(`${this.name}: organization ${t} has no ${this.organizationName} or ${this.organizationDomain}, so its instance cannot be asked for`);const s=this.organizationRequest({id:t,name:a,domain:n}),o=await this.provider.find(s);if(o){const r=t.toLowerCase();if(!o.ready||this.announcing.has(r))return;return i&&await this.server.ldap.modify(e.dn,{},i),void(this.announcing.has(r)||this.announcing.set(r,this.ensureOrganization({id:t,name:a,domain:n}).catch(i=>{this.logger.error({plugin:this.name,event:"organization",id:t,error:String(i)})}).finally(()=>this.announcing.delete(r))))}await this.provider.createOrganization({id:t,name:a,domain:n});try{await this.provider.create(s)}catch(t){if(!await this.provider.find(s))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,a);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){const o={declaredClasses:this.config.user_class};if(first(this.value(i,this.fqdnAttribute))!==a&&await this.server.ldap.modify(t,{replace:{[this.fqdnAttribute]:a}},n,o),first(this.value(i,this.sentAttribute)))return;const c=this.provider instanceof r&&e.orgId&&await this.findOrganization(e.orgId);if(c&&!first(this.value(c,this.sentAttribute))){if(t.toLowerCase().endsWith(`,${c.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`)}const h=s(e.email);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,...h?{matrixId:h}:{},...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,o)}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}=${l(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=c(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(g(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,d=this.offers[this.patterns.findIndex(i=>i.test(t))],l=this.id(s,e);if(l)return{id:l,...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}:{},...d?{offer:d}:{}};this.logger.warn(`${this.name}: ${t} gives no instance slug, no instance`)}match(t){return this.patterns.map(i=>i.exec(t)).find(t=>null!==t)}id(t,i){const e={uid:t,...i.groups};return o((this.config.twake_instance_id||"{uid}").replace(/\{(\w+)\}/g,(t,i)=>e[i]??""))}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=${l(t)})`},this.config.ldap_base,i)).searchEntries.find(t=>this.patterns.some(i=>i.test(t.dn)))}}export{TwakeInstances as default};
|
|
@@ -17,6 +17,14 @@ import { type RequestInit, type Response } from 'node-fetch';
|
|
|
17
17
|
* @returns the slug, empty when nothing of `value` is kept
|
|
18
18
|
*/
|
|
19
19
|
export declare const instanceSlug: (value: string) => string;
|
|
20
|
+
/**
|
|
21
|
+
* The account's Matrix ID, from its mail, for cozy-stack to store as is. A
|
|
22
|
+
* value cozy-stack's `IsMatrixID` would refuse is not sent, nor one whose
|
|
23
|
+
* local part holds a `:`, which a Matrix ID reads as the start of its server.
|
|
24
|
+
*
|
|
25
|
+
* @returns `@local:domain` lowercased, undefined for a malformed mail
|
|
26
|
+
*/
|
|
27
|
+
export declare const matrixId: (mail: string) => string | undefined;
|
|
20
28
|
export interface InstanceRequest {
|
|
21
29
|
/** Slug of the instance, and its OIDC id unless `oidc` says otherwise */
|
|
22
30
|
id: string;
|
|
@@ -194,6 +194,18 @@ export default class LdapGroups extends DmPlugin {
|
|
|
194
194
|
* @param req the request, to authorize the change against its caller
|
|
195
195
|
*/
|
|
196
196
|
deleteMember(cn: string, member: string, req?: Request): Promise<boolean>;
|
|
197
|
+
/**
|
|
198
|
+
* Take a member out of a group. The last member of a `groupOfNames` cannot
|
|
199
|
+
* simply go — the schema wants one (objectClassViolation): the placeholder
|
|
200
|
+
* takes its place in the same change, as it does in a group created empty.
|
|
201
|
+
* With no placeholder configured, the group cannot be left empty, which is
|
|
202
|
+
* the caller's to resolve, not a fault of the server.
|
|
203
|
+
*
|
|
204
|
+
* @param dn group DN
|
|
205
|
+
* @param member member DN
|
|
206
|
+
* @param req the request, to authorize the change against its caller
|
|
207
|
+
*/
|
|
208
|
+
private removeMember;
|
|
197
209
|
deleteMemberFromAll(memberDn: string): Promise<void>;
|
|
198
210
|
/**
|
|
199
211
|
* Move a group to a different organization by updating organization link and path attributes
|
|
@@ -138,9 +138,12 @@ instance: the second is refused, as an instance already there that is not its
|
|
|
138
138
|
own. A name no expression captures stops the server at startup. It is the slug
|
|
139
139
|
of the instance's address; the OIDC id is this slug with the Cloudery, and the
|
|
140
140
|
`uid` with cozy-stack. `user.created` names the account by its `uid`
|
|
141
|
-
(`twakeId`)
|
|
142
|
-
|
|
143
|
-
|
|
141
|
+
(`twakeId`), and gives its Matrix ID as `matrixId`, `@local:domain` from
|
|
142
|
+
its lowercased `mail`; a mail that gives no valid Matrix ID sends none, and
|
|
143
|
+
[cozy-stack](https://github.com/cozy/cozy-stack/blob/3c05972703d8f6c1fe71c9cf1b7a747607a4b395/model/settings/common/common.go#L321-L329)
|
|
144
|
+
derives one from the instance's email. A group named `org` is the organization
|
|
145
|
+
id sent to the provider and in `user.created`, with the mail's domain as its
|
|
146
|
+
domain; with cozy-stack and no such group, `--twake-instance-cozy-org-id` and
|
|
144
147
|
`--twake-instance-cozy-org-domain` stand for them.
|
|
145
148
|
|
|
146
149
|
## Configuration
|
|
@@ -363,7 +363,12 @@ curl -X DELETE "http://localhost:8081/api/v1/ldap/groups/developers/members/uid%
|
|
|
363
363
|
|
|
364
364
|
**Notes:**
|
|
365
365
|
|
|
366
|
-
- If removing the last real member, the dummy member
|
|
366
|
+
- If removing the last real member, the dummy member (`--group-dummy-user`)
|
|
367
|
+
takes its place in the same change, so the group stays valid and reads back
|
|
368
|
+
with no member; with no dummy member configured, the removal is refused
|
|
369
|
+
with a 409, as a `groupOfNames` needs at least one member
|
|
370
|
+
- Removing the dummy member itself is refused with a 400, and a DN that is not
|
|
371
|
+
a member of the group with a 404
|
|
367
372
|
- Member DN must be URL-encoded in the path
|
|
368
373
|
|
|
369
374
|
**Response (200):**
|