ldap-rest 0.25.1 → 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.
|
@@ -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 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;
|
|
@@ -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
|