ldap-rest 2.0.5 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/dist/abstract/twakePlugin.js +1 -1
  2. package/dist/bin/index.js +1 -1
  3. package/dist/lib/auth/base.js +1 -1
  4. package/dist/lib/authz/base.js +1 -1
  5. package/dist/lib/authz/composition.js +1 -1
  6. package/dist/plugins/auth/auth.options.js +1 -1
  7. package/dist/plugins/auth/authzLinid1.js +1 -1
  8. package/dist/plugins/auth/openidconnect.js +1 -1
  9. package/dist/plugins/scim.js +1 -0
  10. package/dist/plugins/twake/calendar.js +1 -1
  11. package/dist/plugins/twake/james.js +1 -1
  12. package/dist/plugins/twake/migrationProxy.js +1 -1
  13. package/dist/src/abstract/twakePlugin.d.ts +25 -0
  14. package/dist/src/config/args.d.ts +3 -0
  15. package/dist/src/lib/auth/base.d.ts +7 -5
  16. package/dist/src/lib/authz/base.d.ts +25 -0
  17. package/dist/src/plugins/auth/auth.options.d.ts +2 -0
  18. package/dist/src/plugins/auth/authzLinid1.d.ts +24 -2
  19. package/dist/src/plugins/auth/openidconnect.d.ts +1 -0
  20. package/dist/src/plugins/scim.d.ts +6 -0
  21. package/dist/src/plugins/twake/calendar.d.ts +0 -7
  22. package/docs/client-development/api/reference.md +3 -2
  23. package/docs/client-development/api/rest-api.md +3 -2
  24. package/docs/usage/configuration.md +10 -8
  25. package/docs/usage/plugins/auth/README.md +1 -1
  26. package/docs/usage/plugins/auth/authz-linid1.md +28 -10
  27. package/docs/usage/plugins/auth/authz-per-branch.md +1 -0
  28. package/docs/usage/plugins/auth/oidc.md +20 -0
  29. package/docs/usage/plugins/integrations/calendar.md +5 -5
  30. package/docs/usage/plugins/integrations/james-mail.md +1 -1
  31. package/docs/usage/upgrading.md +19 -0
  32. package/package.json +8 -1
@@ -1 +1 @@
1
- import t from"p-limit";import{PROBE_TIMEOUT_MS as e}from"../lib/health.js";import{escapeLdapFilter as r}from"../lib/utils.js";import s from"./plugin.js";class TwakePlugin extends s{webadminUrl;webadminToken;requestLimit;retryAttempts=1;retryDelay=0;mailAttr;displayNameAttr;constructor(e,r){super(e);let s=r.url;for(;s.endsWith("/");)s=s.slice(0,-1);if(this.webadminUrl=s,this.webadminToken=r.token,!this.webadminUrl)throw new Error(`Twake plugin: ${r.urlOption} is required`);this.webadminToken||this.logger.warn(`Twake plugin: No authentication token configured (${r.tokenOption}) - requests will be unauthenticated`);const i=r.concurrency;if(!Number.isInteger(i)||i<1)throw new Error(`Twake plugin: ${r.concurrencyOption} must be a positive integer`);this.requestLimit=t(i),this.logger.info(`Twake plugin HTTP request concurrency limit set to ${i}`),this.mailAttr=this.config.mail_attribute||"mail",this.displayNameAttr=this.config.display_name_attribute||"displayName"}createHeaders(t){const e={};return this.webadminToken&&(e.Authorization=`Bearer ${this.webadminToken}`),t&&(e["Content-Type"]=t),e}async callWebAdminApi(t,e,r,s,i,a,n=[]){const o={plugin:this.name,event:t,result:"error",dn:s,...a},u={method:r,headers:this.createHeaders(i?"application/json":void 0)};i&&(u.body=i);for(let r=1;;r++){try{const s=await this.requestLimit(()=>this.fetch(e,u));if(s.ok)return this.logger.info({...o,result:"success",http_status:s.status,url:e}),!0;if(this.shouldRetry(s.status,n,r)){this.logger.warn({...o,result:"retry",attempt:r,http_status:s.status,http_status_text:s.statusText,url:e}),await this.waitBeforeRetry(r);continue}if(this.shouldIgnoreError(s.status,t))return this.logger.debug({...o,result:"ignored",http_status:s.status,http_status_text:s.statusText,url:e}),!0;this.logger.error({...o,...r>1?{attempts:r}:{},http_status:s.status,http_status_text:s.statusText,url:e})}catch(t){this.logger.error({...o,error:t,url:e})}return!1}}async webadminHealth(){try{return(await this.fetch(`${this.webadminUrl}/healthcheck`,{headers:this.createHeaders(),signal:AbortSignal.timeout(e-500)})).ok?"ok":"degraded"}catch{return"degraded"}}async findReadableUser(t,e,s){const i=await this.server.ldap.system.search({scope:"sub",filter:`(uid=${r(t)})`,paged:!1,attributes:s},this.config.ldap_base||"");for(const t of i.searchEntries??[])if(await this.server.ldap.readableBy(String(t.dn),e))return t;return null}shouldRetry(t,e,r){return r<this.retryAttempts&&e.includes(t)}waitBeforeRetry(t){return new Promise(e=>setTimeout(e,this.retryDelay*t))}shouldIgnoreError(t,e){return 409===t&&/alias/i.test(e)}attributeToString(t){return t?Array.isArray(t)?t.length>0?String(t[0]):null:String(t):null}async ldapGetAttributes(t,e){try{const r=e&&e.length>0?e:void 0,s=await this.server.ldap.system.search({paged:!1,scope:"base",attributes:r},t);return s.searchEntries&&s.searchEntries.length>0?s.searchEntries[0]:null}catch(e){return this.logger.debug(`Could not fetch attributes from DN ${t}: ${e}`),null}}getAliases(t){if(!t)return[];return(Array.isArray(t)?t:[t]).map(t=>Buffer.isBuffer(t)?t.toString("utf-8"):String(t)).map(t=>this.normalizeAlias(t))}normalizeAlias(t){return t.toLowerCase().startsWith("smtp:")?t.substring(5):t}extractMailDomain(t){const e=t.split("@");return 2===e.length?e[1]:null}extractDomainFromDn(t){const e=t.match(/dc=([^,]+)/gi);return e?e.map(t=>t.substring(3)).join("."):"example.com"}}export{TwakePlugin,TwakePlugin as default};
1
+ import t from"p-limit";import{PROBE_TIMEOUT_MS as e}from"../lib/health.js";import{escapeLdapFilter as r}from"../lib/utils.js";import s from"./plugin.js";function assertPathSegment(t){if(""!==t&&"."!==t&&".."!==t)return;throw new Error(`Refusing to build a WebAdmin URL from the ${""===t?"empty segment":`dot segment "${t}"`}`)}function encodePathSegment(t){return assertPathSegment(t),encodeURIComponent(t).replace(/%40/g,"@")}class TwakePlugin extends s{webadminUrl;webadminToken;requestLimit;retryAttempts=1;retryDelay=0;mailAttr;displayNameAttr;constructor(e,r){super(e);let s=r.url;for(;s.endsWith("/");)s=s.slice(0,-1);if(this.webadminUrl=s,this.webadminToken=r.token,!this.webadminUrl)throw new Error(`Twake plugin: ${r.urlOption} is required`);this.webadminToken||this.logger.warn(`Twake plugin: No authentication token configured (${r.tokenOption}) - requests will be unauthenticated`);const i=r.concurrency;if(!Number.isInteger(i)||i<1)throw new Error(`Twake plugin: ${r.concurrencyOption} must be a positive integer`);this.requestLimit=t(i),this.logger.info(`Twake plugin HTTP request concurrency limit set to ${i}`),this.mailAttr=this.config.mail_attribute||"mail",this.displayNameAttr=this.config.display_name_attribute||"displayName"}webadminPath(...t){return`${this.webadminUrl}/${t.map(encodePathSegment).join("/")}`}createHeaders(t){const e={};return this.webadminToken&&(e.Authorization=`Bearer ${this.webadminToken}`),t&&(e["Content-Type"]=t),e}async callWebAdminApi(t,e,r,s,i,a,n=[]){const o={plugin:this.name,event:t,result:"error",dn:s,...a},u={method:r,headers:this.createHeaders(i?"application/json":void 0)};i&&(u.body=i);for(let r=1;;r++){try{const s=await this.requestLimit(()=>this.fetch(e,u));if(s.ok)return this.logger.info({...o,result:"success",http_status:s.status,url:e}),!0;if(this.shouldRetry(s.status,n,r)){this.logger.warn({...o,result:"retry",attempt:r,http_status:s.status,http_status_text:s.statusText,url:e}),await this.waitBeforeRetry(r);continue}if(this.shouldIgnoreError(s.status,t))return this.logger.debug({...o,result:"ignored",http_status:s.status,http_status_text:s.statusText,url:e}),!0;this.logger.error({...o,...r>1?{attempts:r}:{},http_status:s.status,http_status_text:s.statusText,url:e})}catch(t){this.logger.error({...o,error:t,url:e})}return!1}}async webadminHealth(){try{return(await this.fetch(`${this.webadminUrl}/healthcheck`,{headers:this.createHeaders(),signal:AbortSignal.timeout(e-500)})).ok?"ok":"degraded"}catch{return"degraded"}}async findReadableUser(t,e,s){const i=await this.server.ldap.system.search({scope:"sub",filter:`(uid=${r(t)})`,paged:!1,attributes:s},this.config.ldap_base||"");for(const t of i.searchEntries??[])if(await this.server.ldap.readableBy(String(t.dn),e))return t;return null}shouldRetry(t,e,r){return r<this.retryAttempts&&e.includes(t)}waitBeforeRetry(t){return new Promise(e=>setTimeout(e,this.retryDelay*t))}shouldIgnoreError(t,e){return 409===t&&/alias/i.test(e)}attributeToString(t){return t?Array.isArray(t)?t.length>0?String(t[0]):null:String(t):null}async ldapGetAttributes(t,e){try{const r=e&&e.length>0?e:void 0,s=await this.server.ldap.system.search({paged:!1,scope:"base",attributes:r},t);return s.searchEntries&&s.searchEntries.length>0?s.searchEntries[0]:null}catch(e){return this.logger.debug(`Could not fetch attributes from DN ${t}: ${e}`),null}}getAliases(t){if(!t)return[];return(Array.isArray(t)?t:[t]).map(t=>Buffer.isBuffer(t)?t.toString("utf-8"):String(t)).map(t=>this.normalizeAlias(t))}normalizeAlias(t){return t.toLowerCase().startsWith("smtp:")?t.substring(5):t}extractMailDomain(t){const e=t.split("@");return 2===e.length?e[1]:null}extractDomainFromDn(t){const e=t.match(/dc=([^,]+)/gi);return e?e.map(t=>t.substring(3)).join("."):"example.com"}}export{TwakePlugin,assertPathSegment,TwakePlugin as default};
package/dist/bin/index.js CHANGED
@@ -1 +1 @@
1
- import{fileURLToPath as e}from"url";import{join as t,dirname as s}from"path";import i from"express";import o from"body-parser";import{ConfigParser as r}from"../lib/parseConfig.js";import{unknownEnvironmentOptions as n}from"../config/options.js";import{keepRawBody as a}from"../lib/rawBody.js";import h from"../config/args.js";import l from"../lib/ldapActions.js";import{buildLogger as c}from"../logger/winston.js";import{setLogger as u,setDebug as d,errorResponse as p,messageOf as g}from"../lib/expressFormatedResponses.js";import{prefixCoversPath as f}from"../lib/auth/base.js";import{assertAuthzComposition as m,assertEnforceable as w,LDAP_JUDGING_HOOKS as b}from"../lib/authz/composition.js";import{registerAttributeTypes as v}from"../lib/utils.js";export{assertRfc4514Escapes,assertedRdnValue,asyncHandler,canonicalDn,escapeDnValue,escapeLdapFilter,escapeRegex,fillPattern,getCompiledRegex,getParentDn,getRdn,hasOnlyRfc4514Escapes,isChildOf,isDirectChildOf,isDnInBranch,isDnSyntax,isDummyMemberDn,knownAttributeName,movedToTrash,namesByDn,normalizeDn,organizationLink,parseDirectoryDate,parseDn,rdnTypes,rdnValue,substringSearchFilter,transformSchemas,unescapeDnValue,validateDnValue}from"../lib/utils.js";import{BadRequestError as y,errorChain as $}from"../lib/errors.js";export{AuthzRefusedError,BadGatewayError,ConflictError,ForbiddenError,GatewayTimeoutError,HttpError,NotFoundError,ServiceUnavailableError,TooManyRequestsError,UnauthorizedError,UriTooLongError}from"../lib/errors.js";import P,{recordHookOwner as j}from"../lib/hookBus.js";export{hookOwner,runAsHookOwner}from"../lib/hookBus.js";import{runWithLogContext as k,runForRequest as S}from"../lib/requestContext.js";import{requestId as O}from"../lib/changeContext.js";import{mountHealth as x,HEALTH_PATHS as D}from"../lib/health.js";import{httpRequestDuration as C,routeTemplate as E,secondsSince as M,unhandledRejections as A}from"../lib/metrics.js";import{registerProcessShutdown as _,shutdownTimeout as R}from"../lib/shutdown.js";import T from"../plugins/priority.json.js";const rejectEmptyMediaType=(e,t,s)=>{const i=e.headers["content-type"];(e=>void 0!==e.headers["transfer-encoding"]||!Number.isNaN(Number(e.headers["content-length"])))(e)&&"string"==typeof i&&""===i.split(";",1)[0].trim()?s(new y("Content-Type names no media type")):s()},stackChain=e=>{const t=$(e).map(e=>e.stack).filter(e=>Boolean(e));return t.length?t.join("\nCaused by: "):void 0},L=b.filter(e=>"ldapsearchfilter"!==e);class DM{app;config;ready;get server(){return this.life.server}set server(e){this.life.server=e}hooks={};hookBus=new P(this.hooks);loadedPlugins={};services=new Map;serviceOwners=new Map;ldap;_operations={sequence:0};get operationSequence(){return this._operations.sequence}set operationSequence(e){this._operations.sequence=e}logger;authenticators=[];_authState={dispatcherMounted:!1};_errorState={};bodyParsers=i.Router();_compositionState={composed:!1};get composed(){return this._compositionState.composed}life={unhandledRejections:0};inFlight=new Set;get stopping(){return void 0!==this.life.shutdown}get unhandledRejections(){return this.life.unhandledRejections}pluginLoads=new Map;unknownCliOptions;deprecationWarnings;authzHooks=new WeakSet;constructor(){const e=new r(h);this.config=e.parse(),this.unknownCliOptions=e.unknownOptions,this.deprecationWarnings=e.deprecationWarnings,this.app=i(),this.app.disable("x-powered-by"),this.app.use((e,t,s)=>{const i=process.hrtime.bigint();t.setHeader("X-Content-Type-Options","nosniff"),this.stopping&&t.setHeader("Connection","close"),this.inFlight.add(t),t.once("finish",()=>{C.observe({method:e.method,route:E(e),status:String(t.statusCode)},M(i)),this.stopping&&this.server?.closeIdleConnections()}),t.once("close",()=>this.inFlight.delete(t)),s()}),this.app.use((e,t,s)=>{const i=O(e);t.setHeader("X-Request-Id",i),k({requestId:i},s)}),this.app.use(rejectEmptyMediaType),this.app.use(this.bodyParsers),this.app.use(o.json({verify:a})),this.app.use(o.urlencoded({extended:!0,verify:a})),x(this.app,this),this.logger=c(this.config);for(const e of this.deprecationWarnings)this.logger.warn(e);this.ldap=new l(this),u(this.logger),d(this.config.debug);const t=[];if(this.config.plugin){const e=this.config.plugin.find(e=>e.includes("configApi"));let s=this.config.plugin.filter(e=>!e.includes("configApi"));const i=this.logger,o=(async()=>{for(const e of T){const t=this.resolveModule(DM.moduleOf(e)),o=s.filter(e=>this.resolveModule(DM.moduleOf(e))===t);if(0!==o.length){s=s.filter(e=>!o.includes(e));for(const e of o)i.debug(`Loading ${e} in the priority pass`),await this.loadPlugin(e)}}})();t.push(o),t.push(o.then(async()=>{const e=s.map(e=>(i.debug(`Loading ${e} in the parallel batch`),this.loadPlugin(e)));await Promise.all(e)})),e&&t.push(Promise.all(t).then(()=>this.loadPlugin(e)))}this.ready=new Promise((e,s)=>{t.length>0?Promise.all(t).then(async()=>{this.setupErrorMiddleware(),this.warnUnauthenticatedRoutes(),await m(this),w(this),await this.learnAttributeTypes(),this._compositionState.composed=!0,this.reportUnknownOptions(),this.afterLoad(),e()}).catch(e=>s(new Error("Error loading plugins: "+e))):(this.setupErrorMiddleware(),this._compositionState.composed=!0,e())})}async learnAttributeTypes(){if(Object.values(this.loadedPlugins).some(e=>e.roles?.includes("authz")))try{v((await this.ldap.schemaIndex()).schema.attributeTypes)}catch(e){this.logger.warn(`Could not read the directory schema, DNs are compared with the usual attribute type names only: ${String(e)}`)}}routerStack(){const e=this.app;return(e.router??e._router)?.stack}withConfig(e){return Object.assign(Object.create(Object.getPrototypeOf(this)),this,{config:e})}reportUnknownOptions(){const e=Object.values(this.loadedPlugins).map(e=>e.constructor.optionDefinitions??{}),t=new Set(e.flatMap(e=>Object.entries(e).flatMap(([e,t])=>[e,...(t.aliases??[]).map(e=>e.replace(/^--?/,"").replace(/-/g,"_"))]))),s=[...this.unknownCliOptions.filter(e=>!t.has(e.replace(/^--?/,"").replace(/-/g,"_"))).map(e=>`option ${e}`),...n(h,process.env,e).map(e=>`environment variable ${e}`)];if(0===s.length)return;const i=`Unknown configuration: ${s.join(", ")} — no declared option reads it`;if(this.config.strict_options)throw new Error(i);this.logger.warn(i)}mountAuthDispatcher(e){const t=e.roles||[];t.includes("protect")||t.includes("logging")||this.mountAuthDispatcherNow()}mountAuthDispatcherNow(){this._authState.dispatcherMounted||(this._authState.dispatcherMounted=!0,this.app.use((e,t,s)=>{this.dispatchAuth(e,t,s)}))}registerAuthenticator(e){this.mountAuthDispatcherNow(),this.authenticators.includes(e)||this.authenticators.push(e)}registerBodyParser(e,t){this.bodyParsers.use(e,t)}dispatchAuth(e,t,s){let i=-1,o=[];for(const t of this.authenticators)for(const s of t.pathPrefixes)f(s,e.path)&&(s.length>i?(i=s.length,o=[t]):s.length!==i||o.includes(t)||o.push(t));if(i<0&&(o=this.authenticators.filter(e=>0===e.pathPrefixes.length)),0===o.length)return s();const finish=i=>{if(i>=o.length)return S(e,s);const r=o[i];if(!r.afterChain)return finish(i+1);r.afterChain(e,t,()=>finish(i+1))},r=[];e.authenticators=r;const run=s=>{if(s>=o.length)return finish(0);const i=o[s];i.authenticate(e,t,()=>{i.vouchedFor(e)&&r.push(i.name),run(s+1)})};run(0)}afterLoad(){for(const e of Object.values(this.loadedPlugins))try{e.afterLoad?.()}catch(t){this.logger.error(`Plugin ${e.name}: afterLoad failed: ${String(t)}`)}}warnUnauthenticatedRoutes(){const e=Object.values(this.loadedPlugins).filter(e=>e.roles?.includes("auth"));if(0===e.length)return[];const t=[];for(const s of e){if(!s.pathPrefixes||0===s.pathPrefixes.length)return[];t.push(...s.pathPrefixes)}const s=this.routerStack(),i=new Set;for(const e of s||[]){const t=e.route?.path;"string"==typeof t?i.add(t):Array.isArray(t)&&t.forEach(e=>"string"==typeof e&&i.add(e))}const o=[...i].filter(e=>!D.includes(e)&&!t.some(t=>e===t||e.startsWith(`${t}/`))),r=Object.values(this.loadedPlugins).some(e=>e.roles?.includes("authz"));return o.length>0&&this.logger.warn(`Authentication is restricted to ${t.join(", ")}, so these routes are served without authentication`+(r?" nor authorization":"")+`: ${o.sort().join(", ")}`),o.sort()}setupErrorMiddleware(){const e=this._errorState.handler;if(e){const t=this.routerStack();if(t){const s=t.findIndex(t=>t.handle===e);-1!==s&&t.splice(s,1)}}const handler=(e,t,s,i)=>{const{status:o,body:r}=p(e,this.config.debug);if(o<500)return this.logger.warn(`Client error ${o} in request ${t.method} ${t.path}: ${r.error}`),void(s.headersSent||s.status(o).json(r));this.logger.error(`Server error ${o} in request ${t.method} ${t.path}: ${g(e)}`,{stack:stackChain(e),url:t.url,method:t.method}),s.headersSent||s.status(o).json(r)};this._errorState.handler=handler,this.app.use(handler)}exitOnCrash(){process.on("uncaughtException",e=>{this.logger.error(`Uncaught exception: ${e.message}`,{stack:stackChain(e)}),setTimeout(()=>process.exit(1),this.config.shutdown_timeout),this.shutdown().finally(()=>process.exit(1))}),process.on("unhandledRejection",e=>{this.life.unhandledRejections++,A.inc(),this.logger.error(`Unhandled promise rejection: ${e instanceof Error?e.message:String(e)}`,{stack:stackChain(e)})})}run(){return new Promise((e,t)=>{this.server=this.app.listen(this.config.port,s=>{s?(this.logger.error(`Error starting server: ${s}`),t(s)):(this.logger.debug(`Server started on port ${this.config.port}`),e())})})}stop(){this.app.removeAllListeners(),this.server?.close(),this.logger.debug("Server stopped")}shutdown(){return this.life.shutdown??=this.letGo(),this.life.shutdown}async letGo(){const e=this.config.shutdown_timeout,t=Date.now(),s=Math.max(1e3,e-5e3);this.logger.notice(`Shutting down, ${s} ms for what is in flight`);const i=this.server;if(i){const e=new Promise(e=>i.close(()=>e()));i.closeIdleConnections();for(const e of this.inFlight)e.headersSent||e.setHeader("Connection","close");await DM.within(e,s),i.closeAllConnections()}const o=new Map;for(const[e,t]of Object.entries(this.loadedPlugins))o.set(e,Promise.resolve().then(()=>t.close?.()).catch(t=>{this.logger.warn(`${e}: close failed: ${t instanceof Error?t.message:String(t)}`)}).finally(()=>o.delete(e)));const r=Promise.all(o.values()).then(()=>this.ldap.close().catch(()=>{})),n=Math.max(1e3,e-(Date.now()-t)-1e3);await DM.within(r,n)||(this.logger.warn(`Shut down with a close still pending: ${[...o.keys()].join(", ")||"the directory connections"}`),this.ldap.close().catch(()=>{})),this.logger.notice("Shut down")}static async within(e,t){let s;const i=new Promise(e=>{s=setTimeout(()=>e(!1),t)});try{return await Promise.race([e.then(()=>!0),i])}finally{clearTimeout(s)}}shutdownOnSignals(){R.ms=this.config.shutdown_timeout,_(()=>this.shutdown())}static moduleOf(e){const t=e.indexOf(":");return-1===t?e:e.substring(0,t)}resolveModule(i){return i.startsWith("core/")?i.replace("core/",t(s(e(import.meta.url)),"..","plugins")+"/").replace(/$/,".js"):i.startsWith(".")?e(new URL(i,import.meta.url)):i}loadPlugin(e){let t,s;if(/:/.test(e)){let i=e.substring(e.indexOf(":")+1);if(e=e.substring(0,e.indexOf(":")),/:/.test(i)){t=i.substring(0,i.indexOf(":")),t||(t=void 0),i=i.substring(i.indexOf(":")+1);try{s=JSON.parse(i),"object"!=typeof s?(this.logger.error(`Overrides for plugin ${e} are not valid: ${i}`),s=void 0):this.logger.debug(`Overrides for plugin ${t||e}: ${i}`)}catch(t){this.logger.error(`Failed to parse overrides for plugin ${e}: ${t}, using ${i}`),s=void 0}}else t=i,t||(t=void 0)}else t=void 0;const i=s?void 0:`${this.resolveModule(e)}\0${t??""}`,o=void 0===i?void 0:this.pluginLoads.get(i);if(o)return this.logger.debug(`Plugin ${e} already loaded, skipping`),o;const r=this.importPlugin(e,t,s);if(void 0!==i){this.pluginLoads.set(i,r);const forget=()=>{this.pluginLoads.get(i)===r&&this.pluginLoads.delete(i)};r.then(e=>e||forget(),forget)}return r}importPlugin(e,t,s){return this.logger.debug(`Loading plugin ${e}`),e.startsWith("core/")&&(e=this.resolveModule(e)),new Promise((i,o)=>{import(e).then(async r=>{let n;if(r&&r.default&&(r=r.default),s){const e={...this.config,...s};n=new r(this.withConfig(e))}else n=new r(this);if(!n)return o(new Error(`Unable to load ${e}`));i(await this.registerPlugin(e,n,t)),this.logger.debug(`Plugin ${n.name} loaded`)}).catch(t=>o(new Error(`Failed to load plugin ${e}: ${t}`)))})}async admitToComposition(e){this.loadedPlugins[e.name]=e;try{await m(this,e)}finally{delete this.loadedPlugins[e.name]}}async registerPlugin(e,t,s){if(t.name||(t.name=e),s&&(t.name=s),this.loadedPlugins[t.name])return this.logger.warn(`Plugin ${e} not registered: the name "${t.name}" is already taken. This instance is dropped and its routes will not exist. To load the same plugin twice, give each one a name: --plugin '${e}:${t.name}2:{…}'`),!1;if(this.logger.debug(`Registering plugin ${e} as ${t.name}`),t.dependencies)for(const e in t.dependencies)this.loadedPlugins[e]||(this.logger.debug(`Plugin ${t.name} depends on ${e}, loading it first`),await this.loadPlugin(t.dependencies[e]));if(this._compositionState.composed&&await this.admitToComposition(t),t.api&&(this.mountAuthDispatcher(t),this.logger.debug(`Plugin ${t.name} has API, registering it`),await t.api(this.app),this._errorState.handler&&this.setupErrorMiddleware()),t.hooks)for(const e in t.hooks){this.logger.debug(`Plugin ${t.name} has hook ${e}, registering it`);const s=t.hooks[e];if(void 0===s)continue;if("function"!=typeof s)throw new Error(`Plugin ${t.name}: hook ${e} is invalid`);const i=e;if(j(s,t.name,e),t.roles?.includes("authz")&&L.includes(i)){const e=this.hookBus.subscribers(i),t=e.findIndex(e=>!this.authzHooks.has(e));this.hookBus.insert(i,s,t<0?e.length:t),this.authzHooks.add(s)}else this.hookBus.subscribe(i,s)}return this.loadedPlugins[t.name]=t,this.registerServices(t),!0}registerServices(e){for(const t of e.provides??[]){const s=this.serviceOwners.get(t.token);void 0===s?(this.services.set(t.token,t.service),this.serviceOwners.set(t.token,e.name)):this.logger.warn(`Plugin ${e.name}: service '${t.token.name}' is already provided by ${s}; this one is ignored`)}}}export{y as BadRequestError,DM,j as recordHookOwner,v as registerAttributeTypes};
1
+ import{fileURLToPath as e}from"url";import{join as t,dirname as s}from"path";import i from"express";import o from"body-parser";import{ConfigParser as r}from"../lib/parseConfig.js";import{unknownEnvironmentOptions as n}from"../config/options.js";import{keepRawBody as a}from"../lib/rawBody.js";import h from"../config/args.js";import l from"../lib/ldapActions.js";import{buildLogger as c}from"../logger/winston.js";import{setLogger as u,setDebug as d,errorResponse as p,messageOf as g}from"../lib/expressFormatedResponses.js";import{prefixCoversPath as f}from"../lib/auth/base.js";import{assertAuthzComposition as m,assertEnforceable as w,LDAP_JUDGING_HOOKS as b}from"../lib/authz/composition.js";import{registerAttributeTypes as v}from"../lib/utils.js";export{assertRfc4514Escapes,assertedRdnValue,asyncHandler,canonicalDn,escapeDnValue,escapeLdapFilter,escapeRegex,fillPattern,getCompiledRegex,getParentDn,getRdn,hasOnlyRfc4514Escapes,isChildOf,isDirectChildOf,isDnInBranch,isDnSyntax,isDummyMemberDn,knownAttributeName,movedToTrash,namesByDn,normalizeDn,organizationLink,parseDirectoryDate,parseDn,rdnTypes,rdnValue,substringSearchFilter,transformSchemas,unescapeDnValue,validateDnValue}from"../lib/utils.js";import{BadRequestError as y,errorChain as $}from"../lib/errors.js";export{AuthzRefusedError,BadGatewayError,ConflictError,ForbiddenError,GatewayTimeoutError,HttpError,NotFoundError,ServiceUnavailableError,TooManyRequestsError,UnauthorizedError,UriTooLongError}from"../lib/errors.js";import P,{recordHookOwner as j}from"../lib/hookBus.js";export{hookOwner,runAsHookOwner}from"../lib/hookBus.js";import{runWithLogContext as k,runForRequest as S}from"../lib/requestContext.js";import{requestId as O}from"../lib/changeContext.js";import{mountHealth as x,HEALTH_PATHS as D}from"../lib/health.js";import{httpRequestDuration as C,routeTemplate as E,secondsSince as M,unhandledRejections as A}from"../lib/metrics.js";import{registerProcessShutdown as _,shutdownTimeout as R}from"../lib/shutdown.js";import T from"../plugins/priority.json.js";const rejectEmptyMediaType=(e,t,s)=>{const i=e.headers["content-type"];(e=>void 0!==e.headers["transfer-encoding"]||!Number.isNaN(Number(e.headers["content-length"])))(e)&&"string"==typeof i&&""===i.split(";",1)[0].trim()?s(new y("Content-Type names no media type")):s()},stackChain=e=>{const t=$(e).map(e=>e.stack).filter(e=>Boolean(e));return t.length?t.join("\nCaused by: "):void 0},L=b.filter(e=>"ldapsearchfilter"!==e);class DM{app;config;ready;get server(){return this.life.server}set server(e){this.life.server=e}hooks={};hookBus=new P(this.hooks);loadedPlugins={};services=new Map;serviceOwners=new Map;ldap;_operations={sequence:0};get operationSequence(){return this._operations.sequence}set operationSequence(e){this._operations.sequence=e}logger;authenticators=[];_authState={dispatcherMounted:!1};_errorState={};bodyParsers=i.Router();_compositionState={composed:!1};get composed(){return this._compositionState.composed}life={unhandledRejections:0};inFlight=new Set;get stopping(){return void 0!==this.life.shutdown}get unhandledRejections(){return this.life.unhandledRejections}pluginLoads=new Map;unknownCliOptions;deprecationWarnings;authzHooks=new WeakSet;constructor(){const e=new r(h);this.config=e.parse(),this.unknownCliOptions=e.unknownOptions,this.deprecationWarnings=e.deprecationWarnings,this.app=i(),this.app.disable("x-powered-by"),this.app.use((e,t,s)=>{const i=process.hrtime.bigint();t.setHeader("X-Content-Type-Options","nosniff"),this.stopping&&t.setHeader("Connection","close"),this.inFlight.add(t),t.once("finish",()=>{C.observe({method:e.method,route:E(e),status:String(t.statusCode)},M(i)),this.stopping&&this.server?.closeIdleConnections()}),t.once("close",()=>this.inFlight.delete(t)),s()}),this.app.use((e,t,s)=>{const i=O(e);t.setHeader("X-Request-Id",i),k({requestId:i},s)}),this.app.use(rejectEmptyMediaType),this.app.use(this.bodyParsers),this.app.use(o.json({verify:a})),this.app.use(o.urlencoded({extended:!0,verify:a})),x(this.app,this),this.logger=c(this.config);for(const e of this.deprecationWarnings)this.logger.warn(e);this.ldap=new l(this),u(this.logger),d(this.config.debug);const t=[];if(this.config.plugin){const e=this.config.plugin.find(e=>e.includes("configApi"));let s=this.config.plugin.filter(e=>!e.includes("configApi"));const i=this.logger,o=(async()=>{for(const e of T){const t=this.resolveModule(DM.moduleOf(e)),o=s.filter(e=>this.resolveModule(DM.moduleOf(e))===t);if(0!==o.length){s=s.filter(e=>!o.includes(e));for(const e of o)i.debug(`Loading ${e} in the priority pass`),await this.loadPlugin(e)}}})();t.push(o),t.push(o.then(async()=>{const e=s.map(e=>(i.debug(`Loading ${e} in the parallel batch`),this.loadPlugin(e)));await Promise.all(e)})),e&&t.push(Promise.all(t).then(()=>this.loadPlugin(e)))}this.ready=new Promise((e,s)=>{t.length>0?Promise.all(t).then(async()=>{this.setupErrorMiddleware(),this.warnUnauthenticatedRoutes(),await m(this),w(this),await this.learnAttributeTypes(),this._compositionState.composed=!0,this.reportUnknownOptions(),this.afterLoad(),e()}).catch(e=>s(new Error("Error loading plugins: "+e))):(this.setupErrorMiddleware(),this._compositionState.composed=!0,e())})}async learnAttributeTypes(){if(Object.values(this.loadedPlugins).some(e=>e.roles?.includes("authz")))try{v((await this.ldap.schemaIndex()).schema.attributeTypes)}catch(e){this.logger.warn(`Could not read the directory schema, DNs are compared with the usual attribute type names only: ${String(e)}`)}}routerStack(){const e=this.app;return(e.router??e._router)?.stack}withConfig(e){return Object.assign(Object.create(Object.getPrototypeOf(this)),this,{config:e})}reportUnknownOptions(){const e=Object.values(this.loadedPlugins).map(e=>e.constructor.optionDefinitions??{}),t=new Set(e.flatMap(e=>Object.entries(e).flatMap(([e,t])=>[e,...(t.aliases??[]).map(e=>e.replace(/^--?/,"").replace(/-/g,"_"))]))),s=[...this.unknownCliOptions.filter(e=>!t.has(e.replace(/^--?/,"").replace(/-/g,"_"))).map(e=>`option ${e}`),...n(h,process.env,e).map(e=>`environment variable ${e}`)];if(0===s.length)return;const i=`Unknown configuration: ${s.join(", ")} — no declared option reads it`;if(this.config.strict_options)throw new Error(i);this.logger.warn(i)}mountAuthDispatcher(e){const t=e.roles||[];t.includes("protect")||t.includes("logging")||this.mountAuthDispatcherNow()}mountAuthDispatcherNow(){this._authState.dispatcherMounted||(this._authState.dispatcherMounted=!0,this.app.use((e,t,s)=>{this.dispatchAuth(e,t,s)}))}registerAuthenticator(e){this.mountAuthDispatcherNow(),this.authenticators.includes(e)||this.authenticators.push(e)}registerBodyParser(e,t){this.bodyParsers.use(e,t)}dispatchAuth(e,t,s){let i=-1,o=[];for(const t of this.authenticators)for(const s of t.pathPrefixes)f(s,e.path)&&(s.length>i?(i=s.length,o=[t]):s.length!==i||o.includes(t)||o.push(t));if(i<0&&(o=this.authenticators.filter(e=>0===e.pathPrefixes.length)),0===o.length)return s();const finish=i=>{if(i>=o.length)return S(e,s);const r=o[i];if(!r.afterChain)return finish(i+1);r.afterChain(e,t,()=>finish(i+1))},r=[];e.authenticators=r;const run=s=>{if(s>=o.length)return finish(0);const i=o[s];i.authenticate(e,t,()=>{i.vouchedFor(e)&&r.push(i.name),run(s+1)})};run(0)}afterLoad(){for(const e of Object.values(this.loadedPlugins))try{e.afterLoad?.()}catch(t){this.logger.error(`Plugin ${e.name}: afterLoad failed: ${String(t)}`)}}warnUnauthenticatedRoutes(){const e=Object.values(this.loadedPlugins).filter(e=>e.roles?.includes("auth"));if(0===e.length)return[];const t=[];for(const s of e){if(!s.pathPrefixes||0===s.pathPrefixes.length)return[];t.push(...s.pathPrefixes)}const s=this.routerStack(),i=new Set;for(const e of s||[]){const t=e.route?.path;"string"==typeof t?i.add(t):Array.isArray(t)&&t.forEach(e=>"string"==typeof e&&i.add(e))}const o=[...i].filter(e=>!D.includes(e)&&!t.some(t=>f(t,e))),r=Object.values(this.loadedPlugins).some(e=>e.roles?.includes("authz"));return o.length>0&&this.logger.warn(`Authentication is restricted to ${t.join(", ")}, so these routes are served without authentication`+(r?" nor authorization":"")+`: ${o.sort().join(", ")}`),o.sort()}setupErrorMiddleware(){const e=this._errorState.handler;if(e){const t=this.routerStack();if(t){const s=t.findIndex(t=>t.handle===e);-1!==s&&t.splice(s,1)}}const handler=(e,t,s,i)=>{const{status:o,body:r}=p(e,this.config.debug);if(o<500)return this.logger.warn(`Client error ${o} in request ${t.method} ${t.path}: ${r.error}`),void(s.headersSent||s.status(o).json(r));this.logger.error(`Server error ${o} in request ${t.method} ${t.path}: ${g(e)}`,{stack:stackChain(e),url:t.url,method:t.method}),s.headersSent||s.status(o).json(r)};this._errorState.handler=handler,this.app.use(handler)}exitOnCrash(){process.on("uncaughtException",e=>{this.logger.error(`Uncaught exception: ${e.message}`,{stack:stackChain(e)}),setTimeout(()=>process.exit(1),this.config.shutdown_timeout),this.shutdown().finally(()=>process.exit(1))}),process.on("unhandledRejection",e=>{this.life.unhandledRejections++,A.inc(),this.logger.error(`Unhandled promise rejection: ${e instanceof Error?e.message:String(e)}`,{stack:stackChain(e)})})}run(){return new Promise((e,t)=>{this.server=this.app.listen(this.config.port,s=>{s?(this.logger.error(`Error starting server: ${s}`),t(s)):(this.logger.debug(`Server started on port ${this.config.port}`),e())})})}stop(){this.app.removeAllListeners(),this.server?.close(),this.logger.debug("Server stopped")}shutdown(){return this.life.shutdown??=this.letGo(),this.life.shutdown}async letGo(){const e=this.config.shutdown_timeout,t=Date.now(),s=Math.max(1e3,e-5e3);this.logger.notice(`Shutting down, ${s} ms for what is in flight`);const i=this.server;if(i){const e=new Promise(e=>i.close(()=>e()));i.closeIdleConnections();for(const e of this.inFlight)e.headersSent||e.setHeader("Connection","close");await DM.within(e,s),i.closeAllConnections()}const o=new Map;for(const[e,t]of Object.entries(this.loadedPlugins))o.set(e,Promise.resolve().then(()=>t.close?.()).catch(t=>{this.logger.warn(`${e}: close failed: ${t instanceof Error?t.message:String(t)}`)}).finally(()=>o.delete(e)));const r=Promise.all(o.values()).then(()=>this.ldap.close().catch(()=>{})),n=Math.max(1e3,e-(Date.now()-t)-1e3);await DM.within(r,n)||(this.logger.warn(`Shut down with a close still pending: ${[...o.keys()].join(", ")||"the directory connections"}`),this.ldap.close().catch(()=>{})),this.logger.notice("Shut down")}static async within(e,t){let s;const i=new Promise(e=>{s=setTimeout(()=>e(!1),t)});try{return await Promise.race([e.then(()=>!0),i])}finally{clearTimeout(s)}}shutdownOnSignals(){R.ms=this.config.shutdown_timeout,_(()=>this.shutdown())}static moduleOf(e){const t=e.indexOf(":");return-1===t?e:e.substring(0,t)}resolveModule(i){return i.startsWith("core/")?i.replace("core/",t(s(e(import.meta.url)),"..","plugins")+"/").replace(/$/,".js"):i.startsWith(".")?e(new URL(i,import.meta.url)):i}loadPlugin(e){let t,s;if(/:/.test(e)){let i=e.substring(e.indexOf(":")+1);if(e=e.substring(0,e.indexOf(":")),/:/.test(i)){t=i.substring(0,i.indexOf(":")),t||(t=void 0),i=i.substring(i.indexOf(":")+1);try{s=JSON.parse(i),"object"!=typeof s?(this.logger.error(`Overrides for plugin ${e} are not valid: ${i}`),s=void 0):this.logger.debug(`Overrides for plugin ${t||e}: ${i}`)}catch(t){this.logger.error(`Failed to parse overrides for plugin ${e}: ${t}, using ${i}`),s=void 0}}else t=i,t||(t=void 0)}else t=void 0;const i=s?void 0:`${this.resolveModule(e)}\0${t??""}`,o=void 0===i?void 0:this.pluginLoads.get(i);if(o)return this.logger.debug(`Plugin ${e} already loaded, skipping`),o;const r=this.importPlugin(e,t,s);if(void 0!==i){this.pluginLoads.set(i,r);const forget=()=>{this.pluginLoads.get(i)===r&&this.pluginLoads.delete(i)};r.then(e=>e||forget(),forget)}return r}importPlugin(e,t,s){return this.logger.debug(`Loading plugin ${e}`),e.startsWith("core/")&&(e=this.resolveModule(e)),new Promise((i,o)=>{import(e).then(async r=>{let n;if(r&&r.default&&(r=r.default),s){const e={...this.config,...s};n=new r(this.withConfig(e))}else n=new r(this);if(!n)return o(new Error(`Unable to load ${e}`));i(await this.registerPlugin(e,n,t)),this.logger.debug(`Plugin ${n.name} loaded`)}).catch(t=>o(new Error(`Failed to load plugin ${e}: ${t}`)))})}async admitToComposition(e){this.loadedPlugins[e.name]=e;try{await m(this,e)}finally{delete this.loadedPlugins[e.name]}}async registerPlugin(e,t,s){if(t.name||(t.name=e),s&&(t.name=s),this.loadedPlugins[t.name])return this.logger.warn(`Plugin ${e} not registered: the name "${t.name}" is already taken. This instance is dropped and its routes will not exist. To load the same plugin twice, give each one a name: --plugin '${e}:${t.name}2:{…}'`),!1;if(this.logger.debug(`Registering plugin ${e} as ${t.name}`),t.dependencies)for(const e in t.dependencies)this.loadedPlugins[e]||(this.logger.debug(`Plugin ${t.name} depends on ${e}, loading it first`),await this.loadPlugin(t.dependencies[e]));if(this._compositionState.composed&&await this.admitToComposition(t),t.api&&(this.mountAuthDispatcher(t),this.logger.debug(`Plugin ${t.name} has API, registering it`),await t.api(this.app),this._errorState.handler&&this.setupErrorMiddleware()),t.hooks)for(const e in t.hooks){this.logger.debug(`Plugin ${t.name} has hook ${e}, registering it`);const s=t.hooks[e];if(void 0===s)continue;if("function"!=typeof s)throw new Error(`Plugin ${t.name}: hook ${e} is invalid`);const i=e;if(j(s,t.name,e),t.roles?.includes("authz")&&L.includes(i)){const e=this.hookBus.subscribers(i),t=e.findIndex(e=>!this.authzHooks.has(e));this.hookBus.insert(i,s,t<0?e.length:t),this.authzHooks.add(s)}else this.hookBus.subscribe(i,s)}return this.loadedPlugins[t.name]=t,this.registerServices(t),!0}registerServices(e){for(const t of e.provides??[]){const s=this.serviceOwners.get(t.token);void 0===s?(this.services.set(t.token,t.service),this.serviceOwners.set(t.token,e.name)):this.logger.warn(`Plugin ${e.name}: service '${t.token.name}' is already provided by ${s}; this one is ignored`)}}}export{y as BadRequestError,DM,j as recordHookOwner,v as registerAttributeTypes};
@@ -1 +1 @@
1
- import e from"../../abstract/plugin.js";import{serverError as t}from"../expressFormatedResponses.js";function stripTrailingSlashes(e){let t=e.length;for(;t>0&&"/"===e[t-1];)t--;return e.slice(0,t)}function prefixCoversPath(e,t){return t===e||t.startsWith(`${e}/`)}const r=["req.user","req.userName"];function assertIdentityMode(e,t){const i=e.authz_identity??"req.user";if(!r.includes(i))throw new Error(`${t}: unknown --authz-identity "${i}". Known: ${r.join(", ")}.`)}function identityFor(e,t){return e?"req.userName"!==t.authz_identity?{value:e.user,fellBack:!1}:e.userName?{value:e.userName,fellBack:!1}:{value:e.user,fellBack:Boolean(e.user)}:{fellBack:!1}}function identityCoverage(e){const t=new Set,r=[];for(const i of Object.values(e)){if(!i.roles?.includes("auth"))continue;const e=i.knownIdentities?.();if(e)for(const r of e)t.add(r);else r.push(i.name)}return{known:t,unverifiable:r}}function warnUnmatchedRuleKeys(e,t,r,i){if(0===e.length)return;const{known:n,unverifiable:s}=identityCoverage(t);if(e.filter(e=>n.has(e)).length>0)return;if(0===n.size&&0===s.length)return;const a=s.length?` ${s.join(", ")} cannot be checked before a login, so this may be right`:"";i.warn(`${r}: none of its configured identities (${e.join(", ")}) is one the loaded authenticators can publish${n.size?` (${[...n].join(", ")})`:""}.${a||" Every rule is inert."}`)}class AuthBase extends e{_pathPrefixes;get pathPrefixes(){if(this._pathPrefixes)return this._pathPrefixes;const e=this.config.auth_path_prefix,t=Array.isArray(e)?e:e?[e]:[],r=[];for(const e of t){if("string"!=typeof e)throw new Error(`${this.name}: auth_path_prefix must contain strings, got ${JSON.stringify(e)}`);const t=e.trim(),i=stripTrailingSlashes(t);if(0===i.length){if(0===t.length)throw new Error(`${this.name}: auth_path_prefix contains an empty entry`);return this.logger.info(`${this.name}: auth_path_prefix contains "${t}", which covers the whole server: this authentication guards every path`),this._pathPrefixes=[]}if(!i.startsWith("/"))throw new Error(`${this.name}: auth_path_prefix entry "${e}" must start with "/", otherwise it matches no request and guards nothing`);r.push(i)}return this._pathPrefixes=r}async authenticate(e,r,i){try{[e,r]=await this.server.hookBus.chain("beforeAuth",[e,r])}catch(e){return t(r,e)}this.authMethod(e,r,async()=>{try{[e,r]=await this.server.hookBus.chain("afterAuth",[e,r]),i()}catch(e){t(r,e)}})}publishIdentity(e,t,r){e.user=t,e.userName=r||t}knownIdentities(){}identitySource(){return"req.user and req.userName: the name this plugin knows the caller by"}afterLoad(){this.logger.info(`${this.name}: ${this.identitySource()}`)}passedThrough=new WeakSet;passThrough(e,t){this.passedThrough.add(e),t()}vouchedFor(e){return!this.passedThrough.has(e)}api(e){const t=this.pathPrefixes;this.server.registerAuthenticator(this),this.logger.info(0===t.length?`${this.name}: authentication guards every path not claimed by another plugin`:`${this.name}: authentication restricted to ${t.join(", ")}`)}}export{assertIdentityMode,AuthBase as default,identityCoverage,identityFor,prefixCoversPath,warnUnmatchedRuleKeys};
1
+ import e from"../../abstract/plugin.js";import{serverError as t}from"../expressFormatedResponses.js";function stripTrailingSlashes(e){let t=e.length;for(;t>0&&"/"===e[t-1];)t--;return e.slice(0,t)}function prefixCoversPath(e,t){const r=e.toLowerCase(),i=t.toLowerCase();return i===r||i.startsWith(`${r}/`)}const r=["req.user","req.userName"];function assertIdentityMode(e,t){const i=e.authz_identity??"req.user";if(!r.includes(i))throw new Error(`${t}: unknown --authz-identity "${i}". Known: ${r.join(", ")}.`)}function identityFor(e,t){return e?"req.userName"!==t.authz_identity?{value:e.user,fellBack:!1}:e.userName?{value:e.userName,fellBack:!1}:{value:e.user,fellBack:Boolean(e.user)}:{fellBack:!1}}function identityCoverage(e){const t=new Set,r=[];for(const i of Object.values(e)){if(!i.roles?.includes("auth"))continue;const e=i.knownIdentities?.();if(e)for(const r of e)t.add(r);else r.push(i.name)}return{known:t,unverifiable:r}}function warnUnmatchedRuleKeys(e,t,r,i){if(0===e.length)return;const{known:n,unverifiable:s}=identityCoverage(t);if(e.filter(e=>n.has(e)).length>0)return;if(0===n.size&&0===s.length)return;const a=s.length?` ${s.join(", ")} cannot be checked before a login, so this may be right`:"";i.warn(`${r}: none of its configured identities (${e.join(", ")}) is one the loaded authenticators can publish${n.size?` (${[...n].join(", ")})`:""}.${a||" Every rule is inert."}`)}class AuthBase extends e{_pathPrefixes;get pathPrefixes(){if(this._pathPrefixes)return this._pathPrefixes;const e=this.config.auth_path_prefix,t=Array.isArray(e)?e:e?[e]:[],r=[];for(const e of t){if("string"!=typeof e)throw new Error(`${this.name}: auth_path_prefix must contain strings, got ${JSON.stringify(e)}`);const t=e.trim(),i=stripTrailingSlashes(t);if(0===i.length){if(0===t.length)throw new Error(`${this.name}: auth_path_prefix contains an empty entry`);return this.logger.info(`${this.name}: auth_path_prefix contains "${t}", which covers the whole server: this authentication guards every path`),this._pathPrefixes=[]}if(!i.startsWith("/"))throw new Error(`${this.name}: auth_path_prefix entry "${e}" must start with "/", otherwise it matches no request and guards nothing`);r.push(i)}return this._pathPrefixes=r}async authenticate(e,r,i){try{[e,r]=await this.server.hookBus.chain("beforeAuth",[e,r])}catch(e){return t(r,e)}this.authMethod(e,r,async()=>{try{[e,r]=await this.server.hookBus.chain("afterAuth",[e,r]),i()}catch(e){t(r,e)}})}publishIdentity(e,t,r){e.user=t,e.userName=r||t}knownIdentities(){}identitySource(){return"req.user and req.userName: the name this plugin knows the caller by"}afterLoad(){this.logger.info(`${this.name}: ${this.identitySource()}`)}passedThrough=new WeakSet;passThrough(e,t){this.passedThrough.add(e),t()}vouchedFor(e){return!this.passedThrough.has(e)}api(e){const t=this.pathPrefixes;this.server.registerAuthenticator(this),this.logger.info(0===t.length?`${this.name}: authentication guards every path not claimed by another plugin`:`${this.name}: authentication restricted to ${t.join(", ")}`)}}export{assertIdentityMode,AuthBase as default,identityCoverage,identityFor,prefixCoversPath,warnUnmatchedRuleKeys};
@@ -1 +1 @@
1
- import t from"../../abstract/plugin.js";import{roleAttribute as e}from"../../config/schema.js";import{assertIdentityMode as s,identityFor as r}from"../auth/base.js";import{BadRequestError as i,ServiceUnavailableError as n,AmbiguousIdentityError as a,AuthzRefusedError as o}from"../errors.js";import{getParentDn as h,isDnInBranch as c,getRdn as u,organizationLink as l,normalizeDn as d,escapeLdapFilter as f,parseDn as w}from"../utils.js";import{authzFor as g,servesRequest as p}from"./composition.js";function mostSpecificBranchRule(t,e){let s=null,r=-1;for(const[i,n]of t){if(!c(e,i))continue;const t=w(i).length;t>r&&([s,r]=[n,t])}return s}class AuthzBase extends t{static widenedForLink=Symbol("widened for the link");static widenedForObjectClass=Symbol("widened for object classes");roles=["authz"];cacheTTL;static UNRESOLVED_POLICIES=["deny","allow"];constructor(t){super(t),s(this.config,this.constructor.name),g(this.config,this.constructor.name);const e=this.config.authz_unresolved_user??"deny";if(!AuthzBase.UNRESOLVED_POLICIES.includes(e))throw new Error(`${this.constructor.name}: unknown --authz-unresolved-user "${e}". Known: ${AuthzBase.UNRESOLVED_POLICIES.join(", ")}.`);this.config.authz_transit_branch&&!this.filtersAttachedEntries()&&this.logger.warn(`${this.constructor.name}: --authz-transit-branch is ignored, entries are not judged by their attachment here (--authz-filter-attached-entries)`)}static wantsAttribute(t,e){const s=t?.attributes;if(void 0===s)return!0;const r=(Array.isArray(s)?s:[s]).map(t=>String(t).toLowerCase());return 0===r.length||(!!r.includes("*")||r.includes(e.toLowerCase()))}extractBranchDn(t){return h(t)}filtersAttachedEntries(){return Boolean(this.config.authz_filter_attached_entries)}judgesByAttachment(){return this.filtersAttachedEntries()}globalBranches(){return[]}async searchRefusal(t,e){if(this.filtersAttachedEntries()){if(0===(await this.getAuthorizedBranches(t)).length)return"administers no branch";if(!this.globalBranches().some(t=>c(e,t)))return}return(await this.getUserPermissions(t,e)).read?void 0:`does not have read permission for branch ${e}`}sourcePermission(t){return"read"}async storedDn(t){try{const e=await this.server.ldap.system.search({paged:!1,scope:"base",attributes:["1.1"]},t),s=e.searchEntries?.[0]?.dn;if(s)return String(s)}catch(e){if(34===e?.code)throw new i(`Invalid DN: ${t}`);if(32!==e?.code)throw this.logger.error(`${this.name}: ${t} could not be read to be authorized: ${String(e)}`),new n("Directory unavailable")}return t}async storedParent(t){const e=h(t);return e===t?t:`${u(t)},${await this.storedDn(e)}`}async effectiveBranch(t){const e=this.config.ldap_organization_link_attribute;if(!e||!this.filtersAttachedEntries())return this.extractBranchDn(t);try{const s=await this.server.ldap.system.search({paged:!1,scope:"base",attributes:[e]},t),r=l(s.searchEntries?.[0]?.[e]);if(r)return r}catch{}return this.extractBranchDn(t)}transitBranch(){if(!this.filtersAttachedEntries())return;const t=String(this.config.authz_transit_branch??"").trim();return t.length>0?t:void 0}inTransit(t,e=this.transitKey()){return void 0===t||void 0!==e&&d(t)===e}transitOriginAttribute(){if(void 0===this.transitBranch())return;const t=String(this.config.authz_transit_origin_attribute??"").trim();return t.length>0?t:void 0}async recordTransitOrigin(t,e){const s=this.transitOriginAttribute(),r=this.config.ldap_organization_link_attribute;if(!s||!r)return e;const same=(t,e)=>t.split(";")[0].toLowerCase()===e.toLowerCase(),named=(t,e)=>void 0!==t&&Object.keys(t).some(t=>same(t,e)),deleted=t=>Array.isArray(e.delete)?e.delete.some(e=>same(e,t)):named(e.delete,t);let i,n;if(named(e.replace,r))i=l(AuthzBase.valuesOf(e.replace,r));else if(named(e.add,r))i=l(AuthzBase.valuesOf(e.add,r));else if(!deleted(r))return e;if(named(e.replace,s)||named(e.add,s)||deleted(s))return e;try{n=(await this.server.ldap.system.search({paged:!1,scope:"base",attributes:[r,s]},t)).searchEntries[0]}catch{}if(!n)return e;const a=l(AuthzBase.valuesOf(n,r)),o=this.inTransit(a),h=this.inTransit(i);return!o&&h&&a?{...e,replace:{...e.replace,[s]:a}}:o&&!h&&AuthzBase.valuesOf(n,s).length>0?{...e,delete:Array.isArray(e.delete)?[...e.delete,s]:{...e.delete,[s]:[]}}:e}transitKey(){const t=this.transitBranch();return void 0===t?void 0:d(t)}async findUserDn(t){const e=`(${this.config.ldap_user_main_attribute||"uid"}=${f(t)})`,s=(await this.server.ldap.system.search({paged:!1,filter:e,attributes:["dn"],scope:"sub"},this.server.ldap.base)).searchEntries??[];if(s.length>1)throw new a(t,s.length);if(0===s.length)return null;const r=s[0].dn;return"string"==typeof r?r:String(r)}onlyRelinks(t){const e=[this.config.ldap_organization_link_attribute,this.config.ldap_organization_path_attribute].filter(t=>"string"==typeof t).map(t=>t.toLowerCase());return[...Object.keys(t.add??{}),...Object.keys(t.replace??{}),...Array.isArray(t.delete)?t.delete:Object.keys(t.delete??{})].every(t=>e.includes(String(t).split(";")[0].toLowerCase()))}async refusingRule(t,e,s,r){const i=d(e);for(const n of await this.ruleBranches(t)){if(!(d(n)===i||r&&c(n,e)))continue;const a=await this.getUserPermissions(t,n);if(s.some(t=>!a[t]))return n}}shouldSkipAuthorization(t){return!t?.user||!p(t,this.config)}resolutionCache=new Map;static RESOLUTION_CACHE_MAX=1e4;resolutionCacheFullWarned=!1;fallbackWarned=!1;async resolveCaller(t){const{value:e,fellBack:s}=r(t,this.config);s&&!this.fallbackWarned&&(this.fallbackWarned=!0,this.logger.warn(`${this.name}: --authz-identity asks for req.userName and the authenticator published none, so permissions are read for req.user instead. Rules written on logins will not match`));const i=e,n=Date.now(),a=this.resolutionCache.get(i);let h;if(a&&n-a.at<this.cacheTTL)h=a.user;else{if(h=await this.resolveUser(i),this.resolutionCache.size>=AuthzBase.RESOLUTION_CACHE_MAX)for(const[t,e]of this.resolutionCache)n-e.at>=this.cacheTTL&&this.resolutionCache.delete(t);this.resolutionCache.size<AuthzBase.RESOLUTION_CACHE_MAX?this.resolutionCache.set(i,{user:h,at:n}):this.resolutionCacheFullWarned||(this.resolutionCacheFullWarned=!0,this.logger.warn(`${this.name}: more than ${AuthzBase.RESOLUTION_CACHE_MAX} identities resolved within one cache window, so resolutions are no longer cached and each request costs a lookup`))}if(h)return h;if("allow"===this.config.authz_unresolved_user)return this.logger.warn(`User ${i} could not be resolved by ${this.name}; allowed by --authz-unresolved-user allow`),null;throw new o(`User ${i} could not be resolved by ${this.name}, so no permission can be read for them`)}domainLinkAttribute(){const t=this.server.loadedPlugins.ldapOrganizations;return e(t?.schema,"domainLink")||this.config.enterprise_domain_link_attribute||void 0}static organizationLinks(t,e){if(e.some(t=>""===t.trim()))throw new i(`${t} cannot hold an empty value`);const s=[...new Map(e.map(t=>[d(t),t])).values()];if(s.length>1)throw new i(`${t} attaches an entry to one organization, not ${s.length}`);return s}static valuesOf(t,e){if(!t)return[];const s=e.toLowerCase(),r=[];for(const[e,i]of Object.entries(t))if(e.split(";")[0].toLowerCase()===s&&null!=i)for(const t of Array.isArray(i)?i:[i])r.push(String(t));return r}async storedValues(t,e){try{const s=await this.server.ldap.system.search({paged:!1,scope:"base",attributes:[e]},t);return AuthzBase.valuesOf(s.searchEntries?.[0],e)}catch{return[]}}async guardDomainLinks(t,e,s,r,i,n){if(0===r.length)return;const a=new Set((n?[]:await this.storedValues(s,i)).map(d)),u=r.filter(t=>!a.has(d(t)));if(0===u.length)return;const l=this.config.ldap_top_organization,f=d(l||this.server.ldap.base),w=new Set;let g=h(s);const p=new Set;for(;g&&!p.has(d(g));){const t=d(g);if(p.add(t),t!==f&&!c(g,f))break;for(const t of await this.storedValues(g,i))w.add(d(t));if(t===f)break;const e=h(g);if(e===g)break;g=e}const m=u.filter(t=>!w.has(d(t)));if(0!==m.length&&!await this.mayLinkAnyDomain(t))throw new o(`User ${e.user} may not link ${s} to domain ${m[0]}: no organization above it is linked to it`)}async mayLinkAnyDomain(t){const e=this.config.ldap_top_organization;return!!e&&!0===(await this.getUserPermissions(t,e)).write}clearResolutionCache(){this.resolutionCache.clear()}hooks={ldaprenamedone:()=>{this.resolutionCache.clear()},ldapdeletedone:()=>{this.resolutionCache.clear()},ldapmodifyrequest:async([t,e,s,r])=>{if(this.shouldSkipAuthorization(r))return[t,await this.recordTransitOrigin(t,e),s,r];const i=await this.resolveCaller(r);if(!i)return[t,await this.recordTransitOrigin(t,e),s,r];const n=await this.storedDn(t),a=this.config.ldap_organization_link_attribute,h=a?AuthzBase.organizationLinks(a,[...AuthzBase.valuesOf(e.replace,a),...AuthzBase.valuesOf(e.add,a)]):[];if(a&&h.length>0){let s,c=this.extractBranchDn(n),u=!1;try{const e=(await this.server.ldap.system.search({paged:!1,scope:"base",attributes:[a]},t)).searchEntries[0];e&&(u=!0,s=l(e[a]),s&&(c=s))}catch{}const d=await this.storedDn(h[0]),f=this.filtersAttachedEntries(),w=f&&u&&this.inTransit(s),g=f&&this.inTransit(d);if(!w){const t=g?"write":this.sourcePermission(c);if(!(await this.getUserPermissions(i,c))[t])throw new o(`User ${r.user} does not have ${t} permission for source branch ${c}`)}if(g){if(w){if(0===(await this.getAuthorizedBranches(i)).length)throw new o(`User ${r.user} administers no branch`)}}else{if(!(await this.getUserPermissions(i,d)).write)throw new o(`User ${r.user} does not have write permission for destination branch ${d}`)}if(!w&&!this.onlyRelinks(e)){const t=f?c:this.extractBranchDn(n);if(!(await this.getUserPermissions(i,t)).write)throw new o(`User ${r.user} does not have write permission for branch ${t}`)}}else{const t=await this.effectiveBranch(n);if(!(await this.getUserPermissions(i,t)).write)throw new o(`User ${r.user} does not have write permission for branch ${t}`)}const c=await this.refusingRule(i,n,["write"],!1);if(c)throw new o(`User ${r.user} does not have write permission for branch ${c}`);const u=this.domainLinkAttribute();return u&&await this.guardDomainLinks(i,r,t,[...AuthzBase.valuesOf(e.add,u),...AuthzBase.valuesOf(e.replace,u)],u,!1),[t,await this.recordTransitOrigin(t,e),s,r]},ldapaddrequest:async([t,e,s])=>{if(this.shouldSkipAuthorization(s))return[t,e,s];const r=await this.resolveCaller(s);if(!r)return[t,e,s];let i;const n=this.config.ldap_organization_link_attribute,a=n?AuthzBase.organizationLinks(n,AuthzBase.valuesOf(e,n)):[],h=await this.storedParent(t);i=a.length>0?await this.storedDn(a[0]):this.extractBranchDn(h);if(!(await this.getUserPermissions(r,i)).write)throw new o(`User ${s.user} does not have write permission for branch ${i}`);const c=await this.refusingRule(r,h,["write"],!1);if(c)throw new o(`User ${s.user} does not have write permission for branch ${c}`);const u=this.domainLinkAttribute();return u&&await this.guardDomainLinks(r,s,t,AuthzBase.valuesOf(e,u),u,!0),[t,e,s]},ldapsearchrequest:async([t,e,s])=>{if(this.shouldSkipAuthorization(s))return[t,e,s];const r=await this.resolveCaller(s);if(!r)return[t,e,s];const i=await this.searchRefusal(r,t);if(i)throw new o(`User ${s.user} ${i}`);if(this.filtersAttachedEntries()){const r=this.config.ldap_organization_link_attribute;if(r&&!AuthzBase.wantsAttribute(e,r)){const t=e.attributes,s=Array.isArray(t)?t.map(String):[String(t)];e={...e,attributes:[...s,r],[AuthzBase.widenedForLink]:!0}}if(r&&!AuthzBase.wantsAttribute(e,"objectClass")){const t=e.attributes,s=Array.isArray(t)?t.map(String):[String(t)];e={...e,attributes:[...s,"objectClass"],[AuthzBase.widenedForObjectClass]:!0}}return[t,e,s]}return[t,e,s]},ldapsearchfilter:async([t,e,s])=>{const r=[t,e,s];if(this.shouldSkipAuthorization(e))return r;if(!t?.searchEntries?.length)return r;const i=await this.resolveCaller(e);if(!i)return r;if(!this.filtersAttachedEntries()){const s=await this.ruleBranches(i),n=[],a=new Map,o=new Set(s.map(d));for(const s of t.searchEntries){const t=String(s.dn),r=d(h(t)),c=o.has(d(t));let u=c?void 0:a.get(r);void 0===u&&(u=!0===(await this.getUserPermissions(i,t)).read,c||a.set(r,u)),u?n.push(s):this.logger.debug(`${this.name}: hiding ${t} from ${e.user}, refused by a narrower branch rule`)}return t.searchEntries=n,r}const n=this.globalBranches();if(n.length){const s=new Map,r=[];for(const a of t.searchEntries){const t=n.find(t=>c(a.dn,t));void 0===t||(s.has(t)||s.set(t,!0===(await this.getUserPermissions(i,t)).read),s.get(t))?r.push(a):this.logger.debug(`${this.name}: hiding ${String(a.dn)} from ${e.user}, in the global branch ${t}`)}t.searchEntries=r}const a=this.config.ldap_organization_link_attribute;if(!a)return r;const o=await this.getAuthorizedBranches(i);if(0===o.length)return r;const u=this.transitKey(),l=new Map,reads=async t=>{if(e=t,!o.some(t=>c(e,t)))return!1;var e;const s=d(t);return l.has(s)||l.set(s,!0===(await this.getUserPermissions(i,t)).read),l.get(s)},readsOrganisation=async t=>{const e=d(t);return l.has(e)||l.set(e,!0===(await this.getUserPermissions(i,t)).read),l.get(e)},f=this.config.ldap_top_organization,w=(this.config.ldap_organization_class??[]).filter(t=>"top"!==t.toLowerCase()).map(t=>t.toLowerCase()),organization=(t,e)=>{if(u&&d(t)===u)return!1;if(!f||!c(t,f))return!1;const s=[].concat(e.objectClass??[]).map(t=>String(t).toLowerCase());return w.some(t=>s.includes(t))},g=[];for(const s of t.searchEntries){const t=s[a],r=null==t?[]:(Array.isArray(t)?t:[t]).map(String);if(0===r.length){const t=String(s.dn);if(organization(t,s)&&!await readsOrganisation(t)){this.logger.debug(`${this.name}: hiding ${t} from ${e.user}, outside the branches they hold`);continue}g.push(s);continue}let i=!1;for(const t of r)if(await reads(t)){i=!0;break}i||r.some(t=>this.inTransit(t,u))?g.push(s):this.logger.debug(`${this.name}: hiding ${String(s.dn)} from ${e.user}, attached to ${r[0]}`)}if(t.searchEntries=g,s?.[AuthzBase.widenedForLink])for(const e of t.searchEntries)delete e[a];if(s?.[AuthzBase.widenedForObjectClass])for(const e of t.searchEntries)delete e.objectClass;return[t,e,s]},getOrganisationTop:async([t,e])=>{if(this.shouldSkipAuthorization(t))return[t,e];const s=await this.resolveCaller(t);if(!s)return[t,e];const r=await this.getAuthorizedBranches(s);if(r.length>0){const e=[];for(const s of r)try{const r=await this.server.ldap.search({paged:!1,scope:"base"},s,t);1===r.searchEntries.length&&e.push(r.searchEntries[0])}catch(t){this.logger.warn(`Failed to fetch authorized branch ${s}: ${t}`)}if(1===e.length)return[t,e[0]];if(e.length>1)return[t,e[0]]}return[t,e]},ldaprenamerequest:async([t,e,s])=>{if(this.shouldSkipAuthorization(s))return[t,e,s];const r=await this.resolveCaller(s);if(!r)return[t,e,s];const i=await this.storedDn(t),n=await this.storedParent(e),a=this.extractBranchDn(i),h=this.extractBranchDn(n),c=await this.getUserPermissions(r,a),u=this.sourcePermission(a);if(!c[u])throw new o(`User ${s.user} does not have ${u} permission for source branch ${a}`);const l=await this.refusingRule(r,i,["read","write"],!0);if(l)throw new o(`User ${s.user} does not have read and write permission for branch ${l}`);if(!(await this.getUserPermissions(r,h)).write)throw new o(`User ${s.user} does not have write permission for destination branch ${h}`);const d=await this.refusingRule(r,n,["write"],!1);if(d)throw new o(`User ${s.user} does not have write permission for branch ${d}`);return[t,e,s]},ldapdeleterequest:async([t,e])=>{if(this.shouldSkipAuthorization(e))return[t,e];const s=await this.resolveCaller(e);if(!s)return[t,e];const r=Array.isArray(t)?t:[t];for(const t of r){const r=await this.storedDn(t),i=await this.effectiveBranch(r);if(!(await this.getUserPermissions(s,i)).delete)throw new o(`User ${e.user} does not have delete permission for branch ${i}`);const n=await this.refusingRule(s,r,["delete"],!0);if(n)throw new o(`User ${e.user} does not have delete permission for branch ${n}`)}return[t,e]}}}export{AuthzBase as default,mostSpecificBranchRule};
1
+ import t from"../../abstract/plugin.js";import{roleAttribute as e}from"../../config/schema.js";import{assertIdentityMode as r,identityFor as s}from"../auth/base.js";import{BadRequestError as i,ServiceUnavailableError as n,AmbiguousIdentityError as a,AuthzRefusedError as o}from"../errors.js";import{getParentDn as h,isDnInBranch as c,getRdn as u,organizationLink as l,normalizeDn as d,escapeLdapFilter as f,parseDn as w}from"../utils.js";import{authzFor as g,servesRequest as m}from"./composition.js";function mostSpecificBranchRule(t,e){let r=null,s=-1;for(const[i,n]of t){if(!c(e,i))continue;const t=w(i).length;t>s&&([r,s]=[n,t])}return r}class AuthzBase extends t{static widenedForLink=Symbol("widened for the link");static widenedForObjectClass=Symbol("widened for object classes");roles=["authz"];cacheTTL;static UNRESOLVED_POLICIES=["deny","allow"];constructor(t){super(t),r(this.config,this.constructor.name),g(this.config,this.constructor.name);const e=this.config.authz_unresolved_user??"deny";if(!AuthzBase.UNRESOLVED_POLICIES.includes(e))throw new Error(`${this.constructor.name}: unknown --authz-unresolved-user "${e}". Known: ${AuthzBase.UNRESOLVED_POLICIES.join(", ")}.`);this.config.authz_transit_branch&&!this.filtersAttachedEntries()&&this.logger.warn(`${this.constructor.name}: --authz-transit-branch is ignored, entries are not judged by their attachment here (--authz-filter-attached-entries)`)}static wantsAttribute(t,e){const r=t?.attributes;if(void 0===r)return!0;const s=(Array.isArray(r)?r:[r]).map(t=>String(t).toLowerCase());return 0===s.length||(!!s.includes("*")||s.includes(e.toLowerCase()))}extractBranchDn(t){return h(t)}filtersAttachedEntries(){return Boolean(this.config.authz_filter_attached_entries)}judgesByAttachment(){return this.filtersAttachedEntries()}globalBranches(){return[]}async searchRefusal(t,e){if(this.filtersAttachedEntries()){if(0===(await this.getAuthorizedBranches(t)).length)return"administers no branch";if(!this.globalBranches().some(t=>c(e,t)))return}return(await this.getUserPermissions(t,e)).read?void 0:`does not have read permission for branch ${e}`}sourcePermission(t){return"read"}async storedDn(t){try{const e=await this.server.ldap.system.search({paged:!1,scope:"base",attributes:["1.1"]},t),r=e.searchEntries?.[0]?.dn;if(r)return String(r)}catch(e){if(34===e?.code)throw new i(`Invalid DN: ${t}`);if(32!==e?.code)throw this.logger.error(`${this.name}: ${t} could not be read to be authorized: ${String(e)}`),new n("Directory unavailable")}return t}async storedParent(t){const e=h(t);return e===t?t:`${u(t)},${await this.storedDn(e)}`}async effectiveBranch(t){const e=this.config.ldap_organization_link_attribute;if(!e||!this.filtersAttachedEntries())return this.extractBranchDn(t);try{const r=await this.server.ldap.system.search({paged:!1,scope:"base",attributes:[e]},t),s=l(r.searchEntries?.[0]?.[e]);if(s)return s}catch{}return this.extractBranchDn(t)}transitBranch(){if(!this.filtersAttachedEntries())return;const t=String(this.config.authz_transit_branch??"").trim();return t.length>0?t:void 0}inTransit(t,e=this.transitKey()){return void 0===t||void 0!==e&&d(t)===e}transitOriginAttribute(){if(void 0===this.transitBranch())return;const t=String(this.config.authz_transit_origin_attribute??"").trim();return t.length>0?t:void 0}async recordTransitOrigin(t,e){const r=this.transitOriginAttribute(),s=this.config.ldap_organization_link_attribute;if(!r||!s)return e;const same=(t,e)=>t.split(";")[0].toLowerCase()===e.toLowerCase(),named=(t,e)=>void 0!==t&&Object.keys(t).some(t=>same(t,e)),deleted=t=>Array.isArray(e.delete)?e.delete.some(e=>same(e,t)):named(e.delete,t);let i,n;if(named(e.replace,s))i=l(AuthzBase.valuesOf(e.replace,s));else if(named(e.add,s))i=l(AuthzBase.valuesOf(e.add,s));else if(!deleted(s))return e;if(named(e.replace,r)||named(e.add,r)||deleted(r))return e;try{n=(await this.server.ldap.system.search({paged:!1,scope:"base",attributes:[s,r]},t)).searchEntries[0]}catch{}if(!n)return e;const a=l(AuthzBase.valuesOf(n,s)),o=this.inTransit(a),h=this.inTransit(i);return!o&&h&&a?{...e,replace:{...e.replace,[r]:a}}:o&&!h&&AuthzBase.valuesOf(n,r).length>0?{...e,delete:Array.isArray(e.delete)?[...e.delete,r]:{...e.delete,[r]:[]}}:e}transitKey(){const t=this.transitBranch();return void 0===t?void 0:d(t)}async findUserDn(t){const e=`(${this.config.ldap_user_main_attribute||"uid"}=${f(t)})`,r=(await this.server.ldap.system.search({paged:!1,filter:e,attributes:["dn"],scope:"sub"},this.server.ldap.base)).searchEntries??[];if(r.length>1)throw new a(t,r.length);if(0===r.length)return null;const s=r[0].dn;return"string"==typeof s?s:String(s)}administeredBranches(t){return Promise.resolve([])}async guardAdministratorAccount(t,e){for(const r of await this.administeredBranches(e))if(!(await this.getUserPermissions(t,r)).write)throw new o(`User ${t} may not write administrator account ${e}: no write permission for branch ${r}`)}onlyRelinks(t){const e=[this.config.ldap_organization_link_attribute,this.config.ldap_organization_path_attribute].filter(t=>"string"==typeof t).map(t=>t.toLowerCase());return[...Object.keys(t.add??{}),...Object.keys(t.replace??{}),...Array.isArray(t.delete)?t.delete:Object.keys(t.delete??{})].every(t=>e.includes(String(t).split(";")[0].toLowerCase()))}async refusingRule(t,e,r,s){const i=d(e);for(const n of await this.ruleBranches(t)){if(!(d(n)===i||s&&c(n,e)))continue;const a=await this.getUserPermissions(t,n);if(r.some(t=>!a[t]))return n}}shouldSkipAuthorization(t){return!t?.user||!m(t,this.config)}resolutionCache=new Map;static RESOLUTION_CACHE_MAX=1e4;resolutionCacheFullWarned=!1;fallbackWarned=!1;async resolveCaller(t){const{value:e,fellBack:r}=s(t,this.config);r&&!this.fallbackWarned&&(this.fallbackWarned=!0,this.logger.warn(`${this.name}: --authz-identity asks for req.userName and the authenticator published none, so permissions are read for req.user instead. Rules written on logins will not match`));const i=e,n=Date.now(),a=this.resolutionCache.get(i);let h;if(a&&n-a.at<this.cacheTTL)h=a.user;else{if(h=await this.resolveUser(i),this.resolutionCache.size>=AuthzBase.RESOLUTION_CACHE_MAX)for(const[t,e]of this.resolutionCache)n-e.at>=this.cacheTTL&&this.resolutionCache.delete(t);this.resolutionCache.size<AuthzBase.RESOLUTION_CACHE_MAX?this.resolutionCache.set(i,{user:h,at:n}):this.resolutionCacheFullWarned||(this.resolutionCacheFullWarned=!0,this.logger.warn(`${this.name}: more than ${AuthzBase.RESOLUTION_CACHE_MAX} identities resolved within one cache window, so resolutions are no longer cached and each request costs a lookup`))}if(h)return h;if("allow"===this.config.authz_unresolved_user)return this.logger.warn(`User ${i} could not be resolved by ${this.name}; allowed by --authz-unresolved-user allow`),null;throw new o(`User ${i} could not be resolved by ${this.name}, so no permission can be read for them`)}domainLinkAttribute(){const t=this.server.loadedPlugins.ldapOrganizations;return e(t?.schema,"domainLink")||this.config.enterprise_domain_link_attribute||void 0}static organizationLinks(t,e){if(e.some(t=>""===t.trim()))throw new i(`${t} cannot hold an empty value`);const r=[...new Map(e.map(t=>[d(t),t])).values()];if(r.length>1)throw new i(`${t} attaches an entry to one organization, not ${r.length}`);return r}static valuesOf(t,e){if(!t)return[];const r=e.toLowerCase(),s=[];for(const[e,i]of Object.entries(t))if(e.split(";")[0].toLowerCase()===r&&null!=i)for(const t of Array.isArray(i)?i:[i])s.push(String(t));return s}async storedValues(t,e){try{const r=await this.server.ldap.system.search({paged:!1,scope:"base",attributes:[e]},t);return AuthzBase.valuesOf(r.searchEntries?.[0],e)}catch{return[]}}async guardDomainLinks(t,e,r,s,i,n){if(0===s.length)return;const a=new Set((n?[]:await this.storedValues(r,i)).map(d)),u=s.filter(t=>!a.has(d(t)));if(0===u.length)return;const l=this.config.ldap_top_organization,f=d(l||this.server.ldap.base),w=new Set;let g=h(r);const m=new Set;for(;g&&!m.has(d(g));){const t=d(g);if(m.add(t),t!==f&&!c(g,f))break;for(const t of await this.storedValues(g,i))w.add(d(t));if(t===f)break;const e=h(g);if(e===g)break;g=e}const p=u.filter(t=>!w.has(d(t)));if(0!==p.length&&!await this.mayLinkAnyDomain(t))throw new o(`User ${e.user} may not link ${r} to domain ${p[0]}: no organization above it is linked to it`)}async mayLinkAnyDomain(t){const e=this.config.ldap_top_organization;return!!e&&!0===(await this.getUserPermissions(t,e)).write}clearResolutionCache(){this.resolutionCache.clear()}hooks={ldaprenamedone:()=>{this.resolutionCache.clear()},ldapdeletedone:()=>{this.resolutionCache.clear()},ldapmodifyrequest:async([t,e,r,s])=>{if(this.shouldSkipAuthorization(s))return[t,await this.recordTransitOrigin(t,e),r,s];const i=await this.resolveCaller(s);if(!i)return[t,await this.recordTransitOrigin(t,e),r,s];const n=await this.storedDn(t);await this.guardAdministratorAccount(i,n);const a=this.config.ldap_organization_link_attribute,h=a?AuthzBase.organizationLinks(a,[...AuthzBase.valuesOf(e.replace,a),...AuthzBase.valuesOf(e.add,a)]):[];if(a&&h.length>0){let r,c=this.extractBranchDn(n),u=!1;try{const e=(await this.server.ldap.system.search({paged:!1,scope:"base",attributes:[a]},t)).searchEntries[0];e&&(u=!0,r=l(e[a]),r&&(c=r))}catch{}const d=await this.storedDn(h[0]),f=this.filtersAttachedEntries(),w=f&&u&&this.inTransit(r),g=f&&this.inTransit(d);if(!w){const t=g?"write":this.sourcePermission(c);if(!(await this.getUserPermissions(i,c))[t])throw new o(`User ${s.user} does not have ${t} permission for source branch ${c}`)}if(g){if(w){if(0===(await this.getAuthorizedBranches(i)).length)throw new o(`User ${s.user} administers no branch`)}}else{if(!(await this.getUserPermissions(i,d)).write)throw new o(`User ${s.user} does not have write permission for destination branch ${d}`)}if(!w&&!this.onlyRelinks(e)){const t=f?c:this.extractBranchDn(n);if(!(await this.getUserPermissions(i,t)).write)throw new o(`User ${s.user} does not have write permission for branch ${t}`)}}else{const t=await this.effectiveBranch(n);if(!(await this.getUserPermissions(i,t)).write)throw new o(`User ${s.user} does not have write permission for branch ${t}`)}const c=await this.refusingRule(i,n,["write"],!1);if(c)throw new o(`User ${s.user} does not have write permission for branch ${c}`);const u=this.domainLinkAttribute();return u&&await this.guardDomainLinks(i,s,t,[...AuthzBase.valuesOf(e.add,u),...AuthzBase.valuesOf(e.replace,u)],u,!1),[t,await this.recordTransitOrigin(t,e),r,s]},ldapaddrequest:async([t,e,r])=>{if(this.shouldSkipAuthorization(r))return[t,e,r];const s=await this.resolveCaller(r);if(!s)return[t,e,r];let i;await this.guardAdministratorAccount(s,t);const n=this.config.ldap_organization_link_attribute,a=n?AuthzBase.organizationLinks(n,AuthzBase.valuesOf(e,n)):[],h=await this.storedParent(t);i=a.length>0?await this.storedDn(a[0]):this.extractBranchDn(h);if(!(await this.getUserPermissions(s,i)).write)throw new o(`User ${r.user} does not have write permission for branch ${i}`);const c=await this.refusingRule(s,h,["write"],!1);if(c)throw new o(`User ${r.user} does not have write permission for branch ${c}`);const u=this.domainLinkAttribute();return u&&await this.guardDomainLinks(s,r,t,AuthzBase.valuesOf(e,u),u,!0),[t,e,r]},ldapsearchrequest:async([t,e,r])=>{if(this.shouldSkipAuthorization(r))return[t,e,r];const s=await this.resolveCaller(r);if(!s)return[t,e,r];const i=await this.searchRefusal(s,t);if(i)throw new o(`User ${r.user} ${i}`);if(this.filtersAttachedEntries()){const s=this.config.ldap_organization_link_attribute;if(s&&!AuthzBase.wantsAttribute(e,s)){const t=e.attributes,r=Array.isArray(t)?t.map(String):[String(t)];e={...e,attributes:[...r,s],[AuthzBase.widenedForLink]:!0}}if(s&&!AuthzBase.wantsAttribute(e,"objectClass")){const t=e.attributes,r=Array.isArray(t)?t.map(String):[String(t)];e={...e,attributes:[...r,"objectClass"],[AuthzBase.widenedForObjectClass]:!0}}return[t,e,r]}return[t,e,r]},ldapsearchfilter:async([t,e,r])=>{const s=[t,e,r];if(this.shouldSkipAuthorization(e))return s;if(!t?.searchEntries?.length)return s;const i=await this.resolveCaller(e);if(!i)return s;if(!this.filtersAttachedEntries()){const r=await this.ruleBranches(i),n=[],a=new Map,o=new Set(r.map(d));for(const r of t.searchEntries){const t=String(r.dn),s=d(h(t)),c=o.has(d(t));let u=c?void 0:a.get(s);void 0===u&&(u=!0===(await this.getUserPermissions(i,t)).read,c||a.set(s,u)),u?n.push(r):this.logger.debug(`${this.name}: hiding ${t} from ${e.user}, refused by a narrower branch rule`)}return t.searchEntries=n,s}const n=this.globalBranches();if(n.length){const r=new Map,s=[];for(const a of t.searchEntries){const t=n.find(t=>c(a.dn,t));void 0===t||(r.has(t)||r.set(t,!0===(await this.getUserPermissions(i,t)).read),r.get(t))?s.push(a):this.logger.debug(`${this.name}: hiding ${String(a.dn)} from ${e.user}, in the global branch ${t}`)}t.searchEntries=s}const a=this.config.ldap_organization_link_attribute;if(!a)return s;const o=await this.getAuthorizedBranches(i);if(0===o.length)return s;const u=this.transitKey(),l=new Map,reads=async t=>{if(e=t,!o.some(t=>c(e,t)))return!1;var e;const r=d(t);return l.has(r)||l.set(r,!0===(await this.getUserPermissions(i,t)).read),l.get(r)},readsOrganisation=async t=>{const e=d(t);return l.has(e)||l.set(e,!0===(await this.getUserPermissions(i,t)).read),l.get(e)},f=this.config.ldap_top_organization,w=(this.config.ldap_organization_class??[]).filter(t=>"top"!==t.toLowerCase()).map(t=>t.toLowerCase()),organization=(t,e)=>{if(u&&d(t)===u)return!1;if(!f||!c(t,f))return!1;const r=[].concat(e.objectClass??[]).map(t=>String(t).toLowerCase());return w.some(t=>r.includes(t))},g=[];for(const r of t.searchEntries){const t=r[a],s=null==t?[]:(Array.isArray(t)?t:[t]).map(String);if(0===s.length){const t=String(r.dn);if(organization(t,r)&&!await readsOrganisation(t)){this.logger.debug(`${this.name}: hiding ${t} from ${e.user}, outside the branches they hold`);continue}g.push(r);continue}let i=!1;for(const t of s)if(await reads(t)){i=!0;break}i||s.some(t=>this.inTransit(t,u))?g.push(r):this.logger.debug(`${this.name}: hiding ${String(r.dn)} from ${e.user}, attached to ${s[0]}`)}if(t.searchEntries=g,r?.[AuthzBase.widenedForLink])for(const e of t.searchEntries)delete e[a];if(r?.[AuthzBase.widenedForObjectClass])for(const e of t.searchEntries)delete e.objectClass;return[t,e,r]},getOrganisationTop:async([t,e])=>{if(this.shouldSkipAuthorization(t))return[t,e];const r=await this.resolveCaller(t);if(!r)return[t,e];const s=await this.getAuthorizedBranches(r);if(s.length>0){const e=[];for(const r of s)try{const s=await this.server.ldap.search({paged:!1,scope:"base"},r,t);1===s.searchEntries.length&&e.push(s.searchEntries[0])}catch(t){this.logger.warn(`Failed to fetch authorized branch ${r}: ${t}`)}if(1===e.length)return[t,e[0]];if(e.length>1)return[t,e[0]]}return[t,e]},ldaprenamerequest:async([t,e,r])=>{if(this.shouldSkipAuthorization(r))return[t,e,r];const s=await this.resolveCaller(r);if(!s)return[t,e,r];const i=await this.storedDn(t);await this.guardAdministratorAccount(s,i);const n=await this.storedParent(e);await this.guardAdministratorAccount(s,n);const a=this.extractBranchDn(i),h=this.extractBranchDn(n),c=await this.getUserPermissions(s,a),u=this.sourcePermission(a);if(!c[u])throw new o(`User ${r.user} does not have ${u} permission for source branch ${a}`);const l=await this.refusingRule(s,i,["read","write"],!0);if(l)throw new o(`User ${r.user} does not have read and write permission for branch ${l}`);if(!(await this.getUserPermissions(s,h)).write)throw new o(`User ${r.user} does not have write permission for destination branch ${h}`);const d=await this.refusingRule(s,n,["write"],!1);if(d)throw new o(`User ${r.user} does not have write permission for branch ${d}`);return[t,e,r]},ldapdeleterequest:async([t,e])=>{if(this.shouldSkipAuthorization(e))return[t,e];const r=await this.resolveCaller(e);if(!r)return[t,e];const s=Array.isArray(t)?t:[t];for(const t of s){const s=await this.storedDn(t);await this.guardAdministratorAccount(r,s);const i=await this.effectiveBranch(s);if(!(await this.getUserPermissions(r,i)).delete)throw new o(`User ${e.user} does not have delete permission for branch ${i}`);const n=await this.refusingRule(r,s,["delete"],!0);if(n)throw new o(`User ${e.user} does not have delete permission for branch ${n}`)}return[t,e]}}}export{AuthzBase as default,mostSpecificBranchRule};
@@ -1 +1 @@
1
- const e=["ldapsearchrequest","ldapaddrequest","ldapmodifyrequest","ldapdeleterequest","ldaprenamerequest","ldapsearchfilter"],t="everyone",n=new WeakMap;function authzFor(e,t){if(n.has(e))return n.get(e);const o=function(e,t){const n=e.authz_for;if(null==n||""===n)return;const o=Array.isArray(n)?n:[n],r=[];for(const e of o){if("string"!=typeof e||""===e.trim())throw new Error(`${t}: authz_for must list authentication plugin names, got `+JSON.stringify(e));r.push(e.trim())}return r.length>0?r:void 0}(e,t);return n.set(e,o),o}function servesRequest(e,t){const n=authzFor(t,"authz_for");if(!n)return!0;const o=e?.authenticators;return!o||0===o.length||o.some(e=>n.includes(e))}function runTogether(e,t){return 0===e.pathPrefixes.length&&0===t.pathPrefixes.length||e.pathPrefixes.some(e=>t.pathPrefixes.includes(e))}function ldapJudges(n){const o=[];for(const r of Object.values(n.loadedPlugins)){if(!r.roles?.includes("authz"))continue;const n=e.filter(e=>Boolean(r.hooks?.[e]));if(0===n.length)continue;const a=r.roles.includes("auth")?[r.name]:authzFor(r.config,r.name)??t;o.push({plugin:r,hooks:n,population:a})}return o}function overlap(e,n,o){if(e===t||n===t)return"same";if(e.some(e=>n.includes(e)))return"same";const byName=e=>o.find(t=>t.name===e);for(const t of e)for(const e of n){const n=byName(t),o=byName(e);if(n&&o&&runTogether(n,o))return"together"}}function describePopulation(e,n){return e!==t?`requests authenticated by ${e.join(", ")}`:n.length>0?`every authenticated request (${n.map(e=>e.name).join(", ")})`:"every authenticated request"}function assertAuthzComposition(e,t){const n=[...e.authenticators];var o;t&&(o=t,Boolean(o.roles?.includes("auth"))&&Array.isArray(o.pathPrefixes))&&!n.some(e=>e.name===t.name)&&n.push(t);const r=n.map(e=>e.name),concerns=e=>!t||e===t,a=Object.values(e.loadedPlugins).filter(concerns);for(const n of a){if(!n.roles?.includes("authz"))continue;if(n.roles.includes("auth")){const t=n.config.authz_for,o=e.config.authz_for;void 0!==t&&JSON.stringify(t)!==JSON.stringify(o)&&"[]"!==JSON.stringify(t)&&e.logger.warn(`${n.name}: authz_for is ignored here. This plugin judges the requests it authenticates itself, and no others`);continue}const o=(authzFor(n.config,n.name)??[]).filter(e=>!r.includes(e));if(o.length>0)throw new Error(`${n.name}: authz_for names ${o.join(", ")}, which `+(o.length>1?"are not loaded authentication plugins":"is not a loaded authentication plugin")+` (loaded: ${r.join(", ")||"none"}). It would judge no request of theirs.`+(t?" Register the authentication plugins before the authorization plugins that name them.":""))}const i=t?void 0:authzFor(e.config,"--authz-for");i&&!Object.values(e.loadedPlugins).some(t=>t.roles?.includes("authz")&&!t.roles.includes("auth")&&t.config.authz_for===e.config.authz_for)&&e.logger.warn(`--authz-for names ${i.join(", ")}, and no loaded plugin reads it: it scopes nothing. authzDynamic judges the requests it authenticates itself; the option is for authzPerBranch, authzLinid1 and authzPerRoute`);const s=ldapJudges(e);for(const t of s)concerns(t.plugin)&&e.logger.info(`${t.plugin.name} judges ${t.hooks.join(", ")} for `+describePopulation(t.population,n));const u=Boolean(e.config.authz_combine),h=[];for(let t=0;t<s.length;t++)for(let o=t+1;o<s.length;o++){const r=s[t],a=s[o];if(!concerns(r.plugin)&&!concerns(a.plugin))continue;const i=overlap(r.population,a.population,n);if(!i)continue;const l=`${r.plugin.name} and ${a.plugin.name}`;"together"===i?e.logger.warn(`${l} judge the requests of authenticators that run on the same paths: a request carrying both credentials is judged by both, and the first refusal wins`):u?e.logger.warn(`${l} both judge the LDAP operations of the same requests, and compose as an AND (--authz-combine): the first refusal wins`):h.push(l)}if(h.length>0)throw new Error(`${h.join("; ")} both judge the LDAP operations of the same requests, and would compose as an AND: the first refusal wins, and the 403 does not say whose it was. Give each an authz_for naming the authentication plugins it serves, so that no request is judged by both, or set --authz-combine to make the AND deliberate.`);return(async()=>{for(const e of a)await(e.assertComposition?.())})()}function assertEnforceable(e){const t=Object.values(e.loadedPlugins).filter(e=>e.roles?.includes("authz")).map(e=>e.name),n=e.authenticators.length>0;if(t.length>0&&!n)throw new Error(`${t.join(", ")} ${t.length>1?"judge":"judges"} requests by their caller's identity, and no authentication plugin is loaded to give them one: every request would pass unjudged. Load an authentication plugin, or remove the authorization ones to serve the directory openly.`);0!==t.length||n?0===t.length&&e.logger.notice("No authorization plugin is loaded: every authenticated caller has every right"):e.logger.warn("No authentication nor authorization plugin is loaded: every request is served with every right")}export{t as EVERYONE,e as LDAP_JUDGING_HOOKS,assertAuthzComposition,assertEnforceable,authzFor,describePopulation,ldapJudges,overlap,runTogether,servesRequest};
1
+ const e=["ldapsearchrequest","ldapaddrequest","ldapmodifyrequest","ldapdeleterequest","ldaprenamerequest","ldapsearchfilter"],t="everyone",n=new WeakMap;function authzFor(e,t){if(n.has(e))return n.get(e);const o=function(e,t){const n=e.authz_for;if(null==n||""===n)return;const o=Array.isArray(n)?n:[n],r=[];for(const e of o){if("string"!=typeof e||""===e.trim())throw new Error(`${t}: authz_for must list authentication plugin names, got `+JSON.stringify(e));r.push(e.trim())}return r.length>0?r:void 0}(e,t);return n.set(e,o),o}function servesRequest(e,t){const n=authzFor(t,"authz_for");if(!n)return!0;const o=e?.authenticators;return!o||0===o.length||o.some(e=>n.includes(e))}function runTogether(e,t){return 0===e.pathPrefixes.length&&0===t.pathPrefixes.length||e.pathPrefixes.some(e=>t.pathPrefixes.some(t=>t.toLowerCase()===e.toLowerCase()))}function ldapJudges(n){const o=[];for(const r of Object.values(n.loadedPlugins)){if(!r.roles?.includes("authz"))continue;const n=e.filter(e=>Boolean(r.hooks?.[e]));if(0===n.length)continue;const a=r.roles.includes("auth")?[r.name]:authzFor(r.config,r.name)??t;o.push({plugin:r,hooks:n,population:a})}return o}function overlap(e,n,o){if(e===t||n===t)return"same";if(e.some(e=>n.includes(e)))return"same";const byName=e=>o.find(t=>t.name===e);for(const t of e)for(const e of n){const n=byName(t),o=byName(e);if(n&&o&&runTogether(n,o))return"together"}}function describePopulation(e,n){return e!==t?`requests authenticated by ${e.join(", ")}`:n.length>0?`every authenticated request (${n.map(e=>e.name).join(", ")})`:"every authenticated request"}function assertAuthzComposition(e,t){const n=[...e.authenticators];var o;t&&(o=t,Boolean(o.roles?.includes("auth"))&&Array.isArray(o.pathPrefixes))&&!n.some(e=>e.name===t.name)&&n.push(t);const r=n.map(e=>e.name),concerns=e=>!t||e===t,a=Object.values(e.loadedPlugins).filter(concerns);for(const n of a){if(!n.roles?.includes("authz"))continue;if(n.roles.includes("auth")){const t=n.config.authz_for,o=e.config.authz_for;void 0!==t&&JSON.stringify(t)!==JSON.stringify(o)&&"[]"!==JSON.stringify(t)&&e.logger.warn(`${n.name}: authz_for is ignored here. This plugin judges the requests it authenticates itself, and no others`);continue}const o=(authzFor(n.config,n.name)??[]).filter(e=>!r.includes(e));if(o.length>0)throw new Error(`${n.name}: authz_for names ${o.join(", ")}, which `+(o.length>1?"are not loaded authentication plugins":"is not a loaded authentication plugin")+` (loaded: ${r.join(", ")||"none"}). It would judge no request of theirs.`+(t?" Register the authentication plugins before the authorization plugins that name them.":""))}const i=t?void 0:authzFor(e.config,"--authz-for");i&&!Object.values(e.loadedPlugins).some(t=>t.roles?.includes("authz")&&!t.roles.includes("auth")&&t.config.authz_for===e.config.authz_for)&&e.logger.warn(`--authz-for names ${i.join(", ")}, and no loaded plugin reads it: it scopes nothing. authzDynamic judges the requests it authenticates itself; the option is for authzPerBranch, authzLinid1 and authzPerRoute`);const s=ldapJudges(e);for(const t of s)concerns(t.plugin)&&e.logger.info(`${t.plugin.name} judges ${t.hooks.join(", ")} for `+describePopulation(t.population,n));const u=Boolean(e.config.authz_combine),h=[];for(let t=0;t<s.length;t++)for(let o=t+1;o<s.length;o++){const r=s[t],a=s[o];if(!concerns(r.plugin)&&!concerns(a.plugin))continue;const i=overlap(r.population,a.population,n);if(!i)continue;const l=`${r.plugin.name} and ${a.plugin.name}`;"together"===i?e.logger.warn(`${l} judge the requests of authenticators that run on the same paths: a request carrying both credentials is judged by both, and the first refusal wins`):u?e.logger.warn(`${l} both judge the LDAP operations of the same requests, and compose as an AND (--authz-combine): the first refusal wins`):h.push(l)}if(h.length>0)throw new Error(`${h.join("; ")} both judge the LDAP operations of the same requests, and would compose as an AND: the first refusal wins, and the 403 does not say whose it was. Give each an authz_for naming the authentication plugins it serves, so that no request is judged by both, or set --authz-combine to make the AND deliberate.`);return(async()=>{for(const e of a)await(e.assertComposition?.())})()}function assertEnforceable(e){const t=Object.values(e.loadedPlugins).filter(e=>e.roles?.includes("authz")).map(e=>e.name),n=e.authenticators.length>0;if(t.length>0&&!n)throw new Error(`${t.join(", ")} ${t.length>1?"judge":"judges"} requests by their caller's identity, and no authentication plugin is loaded to give them one: every request would pass unjudged. Load an authentication plugin, or remove the authorization ones to serve the directory openly.`);0!==t.length||n?0===t.length&&e.logger.notice("No authorization plugin is loaded: every authenticated caller has every right"):e.logger.warn("No authentication nor authorization plugin is loaded: every request is served with every right")}export{t as EVERYONE,e as LDAP_JUDGING_HOOKS,assertAuthzComposition,assertEnforceable,authzFor,describePopulation,ldapJudges,overlap,runTogether,servesRequest};
@@ -1 +1 @@
1
- import{defineOptions as _,opt as t,readOptions as e}from"../../config/options.js";const n=_({auth_token:t.array({env:"DM_AUTH_TOKENS",default:[],plural:"auth-tokens"})}),a=_({auth_fake_user:t.string({env:"DM_AUTH_FAKE_USER",default:""})}),r=_({auth_totp:t.array({env:"DM_AUTH_TOTP",default:[],plural:"auth-totps",kind:"phrases"}),auth_totp_window:t.number({env:"DM_AUTH_TOTP_WINDOW",default:1}),auth_totp_step:t.number({env:"DM_AUTH_TOTP_STEP",default:30,min:1})}),i=_({auth_hmac:t.array({env:"DM_AUTH_HMAC",default:[],plural:"auth-hmacs",kind:"phrases"}),auth_hmac_window:t.number({env:"DM_AUTH_HMAC_WINDOW",default:12e4,min:1})}),u=_({llng_ini:t.string({env:"DM_LLNG_INI",default:"/etc/lemonldap-ng/lemonldap-ng.ini"}),llng_username_header:t.string({env:"DM_LLNG_USERNAME_HEADER",default:""})}),l=_({authz_per_route:t.array({env:"DM_AUTHZ_PER_ROUTES",default:[],plural:"authz-per-routes"})}),d=_({authz_per_branch_config:t.json({env:"DM_AUTHZ_PER_BRANCH_CONFIG",default:{default:{read:!0,write:!1,delete:!1}}}),authz_per_branch_cache_ttl:t.number({env:"DM_AUTHZ_PER_BRANCH_CACHE_TTL",default:60,min:1}),ldap_group_member_attribute:t.string({env:"DM_LDAP_GROUP_MEMBER_ATTRIBUTE",default:"member"})}),o=_({authz_dynamic_bypass:t.array({env:"DM_AUTHZ_DYNAMIC_BYPASS",default:[],kind:"identifiers"}),authz_dynamic_base:t.string({env:"DM_AUTHZ_DYNAMIC_BASE",default:""}),authz_dynamic_cache_ttl:t.number({env:"DM_AUTHZ_DYNAMIC_CACHE_TTL",default:60,min:1}),authz_dynamic_token_attribute:t.string({env:"DM_AUTHZ_DYNAMIC_TOKEN_ATTRIBUTE",default:"userPassword"}),authz_dynamic_config_attribute:t.string({env:"DM_AUTHZ_DYNAMIC_CONFIG_ATTRIBUTE",default:"description"}),authz_dynamic_tenant_attribute:t.string({env:"DM_AUTHZ_DYNAMIC_TENANT_ATTRIBUTE",default:"cn"}),authz_dynamic_reload_endpoint:t.boolean({env:"DM_AUTHZ_DYNAMIC_RELOAD_ENDPOINT",default:!1})}),s=_({authz_local_admin_attribute:t.string({env:"DM_AUTHZ_LOCAL_ADMIN_ATTRIBUTE",default:"twakeLocalAdminLink"}),authz_global_branch:t.array({env:"DM_AUTHZ_GLOBAL_BRANCHES",default:[],plural:"authz-global-branches",kind:"dns"}),authz_global_write_branch:t.array({env:"DM_AUTHZ_GLOBAL_WRITE_BRANCHES",default:[],plural:"authz-global-write-branches",kind:"dns"}),authz_global_organization:t.string({env:"DM_AUTHZ_GLOBAL_ORGANIZATION",default:""})}),T=_({oidc_server:t.string({env:"DM_OIDC_SERVER",default:""}),oidc_username_claim:t.string({env:"DM_OIDC_USERNAME_CLAIM",default:"sub"}),oidc_client_id:t.string({env:"DM_OIDC_CLIENT_ID",default:""}),oidc_client_secret:t.string({env:"DM_OIDC_CLIENT_SECRET",default:""}),base_url:t.string({env:"DM_BASE_URL",default:""}),oidc_logout_origins:t.array({env:"DM_OIDC_LOGOUT_ORIGINS",default:[],plural:"oidc-logout-origins"})}),A=_({rate_limit_window_ms:t.number({env:"DM_RATE_LIMIT_WINDOW_MS",default:9e5,min:1}),rate_limit_max:t.number({env:"DM_RATE_LIMIT_MAX",default:100,min:1})}),c=_({crowdsec_url:t.string({env:"DM_CROWDSEC_URL",default:"http://localhost:8080/v1/decisions"}),crowdsec_api_key:t.string({env:"DM_CROWDSEC_API_KEY",default:""}),crowdsec_cache_ttl:t.number({env:"DM_CROWDSEC_CACHE_TTL",default:60,min:1})}),D=_({trusted_proxy:t.array({env:"DM_TRUSTED_PROXIES",default:[],plural:"trusted-proxies",kind:"identifiers"}),trusted_proxy_auth_header:t.string({env:"DM_TRUSTED_PROXY_AUTH_HEADER",default:"Auth-User"})});function totpOptionsOf(_){return e(r,_)}function hmacOptionsOf(_){return e(i,_)}function perBranchOptionsOf(_){return e(d,_)}function dynamicOptionsOf(_){return e(o,_)}function rateLimitOptionsOf(_){return e(A,_)}function crowdsecOptionsOf(_){return e(c,_)}export{c as crowdsecOptions,crowdsecOptionsOf,o as dynamicOptions,dynamicOptionsOf,a as fakeOptions,i as hmacOptions,hmacOptionsOf,s as linid1Options,u as llngOptions,T as openidconnectOptions,d as perBranchOptions,perBranchOptionsOf,l as perRouteOptions,A as rateLimitOptions,rateLimitOptionsOf,n as tokenOptions,r as totpOptions,totpOptionsOf,D as trustedProxyOptions};
1
+ import{defineOptions as _,opt as t,readOptions as e}from"../../config/options.js";const n=_({auth_token:t.array({env:"DM_AUTH_TOKENS",default:[],plural:"auth-tokens"})}),a=_({auth_fake_user:t.string({env:"DM_AUTH_FAKE_USER",default:""})}),r=_({auth_totp:t.array({env:"DM_AUTH_TOTP",default:[],plural:"auth-totps",kind:"phrases"}),auth_totp_window:t.number({env:"DM_AUTH_TOTP_WINDOW",default:1}),auth_totp_step:t.number({env:"DM_AUTH_TOTP_STEP",default:30,min:1})}),i=_({auth_hmac:t.array({env:"DM_AUTH_HMAC",default:[],plural:"auth-hmacs",kind:"phrases"}),auth_hmac_window:t.number({env:"DM_AUTH_HMAC_WINDOW",default:12e4,min:1})}),u=_({llng_ini:t.string({env:"DM_LLNG_INI",default:"/etc/lemonldap-ng/lemonldap-ng.ini"}),llng_username_header:t.string({env:"DM_LLNG_USERNAME_HEADER",default:""})}),l=_({authz_per_route:t.array({env:"DM_AUTHZ_PER_ROUTES",default:[],plural:"authz-per-routes"})}),d=_({authz_per_branch_config:t.json({env:"DM_AUTHZ_PER_BRANCH_CONFIG",default:{default:{read:!0,write:!1,delete:!1}}}),authz_per_branch_cache_ttl:t.number({env:"DM_AUTHZ_PER_BRANCH_CACHE_TTL",default:60,min:1}),ldap_group_member_attribute:t.string({env:"DM_LDAP_GROUP_MEMBER_ATTRIBUTE",default:"member"})}),s=_({authz_dynamic_bypass:t.array({env:"DM_AUTHZ_DYNAMIC_BYPASS",default:[],kind:"identifiers"}),authz_dynamic_base:t.string({env:"DM_AUTHZ_DYNAMIC_BASE",default:""}),authz_dynamic_cache_ttl:t.number({env:"DM_AUTHZ_DYNAMIC_CACHE_TTL",default:60,min:1}),authz_dynamic_token_attribute:t.string({env:"DM_AUTHZ_DYNAMIC_TOKEN_ATTRIBUTE",default:"userPassword"}),authz_dynamic_config_attribute:t.string({env:"DM_AUTHZ_DYNAMIC_CONFIG_ATTRIBUTE",default:"description"}),authz_dynamic_tenant_attribute:t.string({env:"DM_AUTHZ_DYNAMIC_TENANT_ATTRIBUTE",default:"cn"}),authz_dynamic_reload_endpoint:t.boolean({env:"DM_AUTHZ_DYNAMIC_RELOAD_ENDPOINT",default:!1})}),o=_({authz_local_admin_attribute:t.string({env:"DM_AUTHZ_LOCAL_ADMIN_ATTRIBUTE",default:"twakeLocalAdminLink"}),authz_linid1_cache_ttl:t.number({env:"DM_AUTHZ_LINID1_CACHE_TTL",default:300,min:1}),authz_global_branch:t.array({env:"DM_AUTHZ_GLOBAL_BRANCHES",default:[],plural:"authz-global-branches",kind:"dns"}),authz_global_write_branch:t.array({env:"DM_AUTHZ_GLOBAL_WRITE_BRANCHES",default:[],plural:"authz-global-write-branches",kind:"dns"}),authz_global_organization:t.string({env:"DM_AUTHZ_GLOBAL_ORGANIZATION",default:""})}),T=_({oidc_server:t.string({env:"DM_OIDC_SERVER",default:""}),oidc_username_claim:t.string({env:"DM_OIDC_USERNAME_CLAIM",default:"sub"}),oidc_client_id:t.string({env:"DM_OIDC_CLIENT_ID",default:""}),oidc_client_secret:t.string({env:"DM_OIDC_CLIENT_SECRET",default:""}),oidc_session_secret:t.array({env:"DM_OIDC_SESSION_SECRET",default:[],plural:"oidc-session-secrets",kind:"phrases"}),base_url:t.string({env:"DM_BASE_URL",default:""}),oidc_logout_origins:t.array({env:"DM_OIDC_LOGOUT_ORIGINS",default:[],plural:"oidc-logout-origins"})}),c=_({rate_limit_window_ms:t.number({env:"DM_RATE_LIMIT_WINDOW_MS",default:9e5,min:1}),rate_limit_max:t.number({env:"DM_RATE_LIMIT_MAX",default:100,min:1})}),A=_({crowdsec_url:t.string({env:"DM_CROWDSEC_URL",default:"http://localhost:8080/v1/decisions"}),crowdsec_api_key:t.string({env:"DM_CROWDSEC_API_KEY",default:""}),crowdsec_cache_ttl:t.number({env:"DM_CROWDSEC_CACHE_TTL",default:60,min:1})}),D=_({trusted_proxy:t.array({env:"DM_TRUSTED_PROXIES",default:[],plural:"trusted-proxies",kind:"identifiers"}),trusted_proxy_auth_header:t.string({env:"DM_TRUSTED_PROXY_AUTH_HEADER",default:"Auth-User"})});function totpOptionsOf(_){return e(r,_)}function hmacOptionsOf(_){return e(i,_)}function perBranchOptionsOf(_){return e(d,_)}function dynamicOptionsOf(_){return e(s,_)}function rateLimitOptionsOf(_){return e(c,_)}function crowdsecOptionsOf(_){return e(A,_)}export{A as crowdsecOptions,crowdsecOptionsOf,s as dynamicOptions,dynamicOptionsOf,a as fakeOptions,i as hmacOptions,hmacOptionsOf,o as linid1Options,u as llngOptions,T as openidconnectOptions,d as perBranchOptions,perBranchOptionsOf,l as perRouteOptions,c as rateLimitOptions,rateLimitOptionsOf,n as tokenOptions,r as totpOptions,totpOptionsOf,D as trustedProxyOptions};
@@ -1 +1 @@
1
- import t,{mostSpecificBranchRule as e}from"../../lib/authz/base.js";import{isDnInBranch as i,escapeLdapFilter as s}from"../../lib/utils.js";import{linid1Options as r}from"./auth.options.js";class AuthzLinid1 extends t{static optionDefinitions=r;name="authzLinid1";permissionsCache=new Map;constructor(t){super(t),this.cacheTTL=3e5;const e=this.options(r).authz_local_admin_attribute;this.logger.info(`AuthzLinid1: Authorization based on ${e} attribute enabled`);const s=this.globalOrganization(),n=s&&this.globalBranches().find(t=>i(s,t));if(n)throw new Error(`AuthzLinid1: --authz-global-organization ${s} lies in the global branch ${n}`);const a=s&&this.globalWriteBranches().find(t=>i(s,t));if(a)throw new Error(`AuthzLinid1: --authz-global-organization ${s} lies in the global write branch ${a}`);for(const t of this.globalWriteBranches()){const e=this.globalBranches().find(e=>i(t,e)||i(e,t));if(e)throw new Error(`AuthzLinid1: the global branch ${e} and the global write branch ${t} overlap`)}}async mayLinkAnyDomain(t){if(await super.mayLinkAnyDomain(t))return!0;const e=this.globalOrganization();return!!e&&!0===(await this.getUserPermissions(t,e)).write}globalOrganization(){return this.options(r).authz_global_organization||this.config.ldap_top_organization||void 0}filtersAttachedEntries(){return!0}globalBranches(){return this.options(r).authz_global_branch}globalWriteBranches(){return this.options(r).authz_global_write_branch}isGlobalWrite(t){return this.globalWriteBranches().some(e=>i(t,e))}sourcePermission(t){return this.isGlobalWrite(t)?"delete":"read"}async resolveUser(t){return this.getUserDn(t)}async getUserDn(t){return this.findUserDn(t)}async getUserPermissions(t,e){const s=this.globalOrganization();if(this.globalBranches().some(t=>i(e,t)))return s&&!i(s,e)?this.getUserPermissions(t,s):{read:!1,write:!1,delete:!1};if(this.isGlobalWrite(e)){const r=s&&!i(s,e)?await this.getUserPermissions(t,s):{read:!1,write:!1,delete:!1};return{read:r.read||(await this.getAuthorizedBranches(t)).length>0,write:r.write,delete:r.delete}}return this.ownPermissions(t,e)}async ownPermissions(t,e){const i=Date.now(),s=this.permissionsCache.get(t);if(s&&i-s.timestamp<this.cacheTTL){const t=this.findBranchPermissions(s.branches,e);if(t)return t}await this.refreshUserPermissions(t);const r=this.permissionsCache.get(t);if(r){const t=this.findBranchPermissions(r.branches,e);if(t)return t}return{read:!1,write:!1,delete:!1}}async refreshUserPermissions(t){const e=new Map;try{const i=`(${this.options(r).authz_local_admin_attribute}=${s(t)})`,n=this.config.ldap_top_organization||this.config.ldap_base||"",a=await this.server.ldap.system.search({paged:!1,filter:i,attributes:["dn"],scope:"sub"},n);if(a.searchEntries)for(const t of a.searchEntries){const i="string"==typeof t.dn?t.dn:String(t.dn);e.set(i,{read:!0,write:!0,delete:!0})}}catch(e){this.logger.error(`Failed to refresh permissions for user ${t}: ${e}`)}this.permissionsCache.set(t,{branches:e,timestamp:Date.now()})}async getAuthorizedBranches(t){const e=Date.now(),i=this.permissionsCache.get(t);(!i||e-i.timestamp>=this.cacheTTL)&&await this.refreshUserPermissions(t);const s=this.permissionsCache.get(t);return s?Array.from(s.branches.keys()):[]}ruleBranches(t){return this.getAuthorizedBranches(t)}findBranchPermissions(t,i){return e(t.entries(),i)}}export{AuthzLinid1 as default};
1
+ import e,{mostSpecificBranchRule as s}from"../../lib/authz/base.js";import{isDnInBranch as t,escapeLdapFilter as i}from"../../lib/utils.js";import{linid1Options as r}from"./auth.options.js";class AuthzLinid1 extends e{static optionDefinitions=r;name="authzLinid1";permissionsCache=new Map;permissionsGeneration=0;declaredByBase;constructor(e){super(e),this.declaredByBase={...this.hooks},this.cacheTTL=1e3*this.options(r).authz_linid1_cache_ttl;const s=this.options(r).authz_local_admin_attribute;this.logger.info(`AuthzLinid1: Authorization based on ${s} attribute enabled`);const i=this.globalOrganization(),n=i&&this.globalBranches().find(e=>t(i,e));if(n)throw new Error(`AuthzLinid1: --authz-global-organization ${i} lies in the global branch ${n}`);const a=i&&this.globalWriteBranches().find(e=>t(i,e));if(a)throw new Error(`AuthzLinid1: --authz-global-organization ${i} lies in the global write branch ${a}`);for(const e of this.globalWriteBranches()){const s=this.globalBranches().find(s=>t(e,s)||t(s,e));if(s)throw new Error(`AuthzLinid1: the global branch ${s} and the global write branch ${e} overlap`)}this.hooks={...this.declaredByBase,ldapmodifydone:(e,t,i,r)=>((e=>[...Object.keys(e.add??{}),...Object.keys(e.replace??{}),...Array.isArray(e.delete)?e.delete:Object.keys(e.delete??{})].some(e=>e.split(";")[0].toLowerCase()===s.toLowerCase()))(t)&&this.forgetPermissions(),this.declaredByBase.ldapmodifydone?.(e,t,i,r)),ldapdeletedone:(e,s)=>(this.forgetPermissions(),this.declaredByBase.ldapdeletedone?.(e,s)),ldaprenamedone:(e,s,t)=>(this.forgetPermissions(),this.declaredByBase.ldaprenamedone?.(e,s,t))}}forgetPermissions(){this.permissionsGeneration++,this.permissionsCache.clear()}async administeredBranches(e){const s=this.options(r).authz_local_admin_attribute,t=this.config.ldap_top_organization||this.config.ldap_base||"";if(!t)return[];return((await this.server.ldap.system.search({paged:!1,filter:`(${s}=${i(e)})`,attributes:["dn"],scope:"sub"},t)).searchEntries??[]).map(e=>String(e.dn))}async mayLinkAnyDomain(e){if(await super.mayLinkAnyDomain(e))return!0;const s=this.globalOrganization();return!!s&&!0===(await this.getUserPermissions(e,s)).write}globalOrganization(){return this.options(r).authz_global_organization||this.config.ldap_top_organization||void 0}filtersAttachedEntries(){return!0}globalBranches(){return this.options(r).authz_global_branch}globalWriteBranches(){return this.options(r).authz_global_write_branch}isGlobalWrite(e){return this.globalWriteBranches().some(s=>t(e,s))}sourcePermission(e){return this.isGlobalWrite(e)?"delete":"read"}async resolveUser(e){return this.getUserDn(e)}async getUserDn(e){return this.findUserDn(e)}async getUserPermissions(e,s){const i=this.globalOrganization();if(this.globalBranches().some(e=>t(s,e)))return i&&!t(i,s)?this.getUserPermissions(e,i):{read:!1,write:!1,delete:!1};if(this.isGlobalWrite(s)){const r=i&&!t(i,s)?await this.getUserPermissions(e,i):{read:!1,write:!1,delete:!1};return{read:r.read||(await this.getAuthorizedBranches(e)).length>0,write:r.write,delete:r.delete}}return this.ownPermissions(e,s)}async ownPermissions(e,s){const t=Date.now(),i=this.permissionsCache.get(e);if(i&&t-i.timestamp<this.cacheTTL){const e=this.findBranchPermissions(i.branches,s);if(e)return e}const r=await this.refreshUserPermissions(e),n=this.findBranchPermissions(r,s);return n||{read:!1,write:!1,delete:!1}}async refreshUserPermissions(e){const s=this.permissionsGeneration,t=new Map;try{const s=`(${this.options(r).authz_local_admin_attribute}=${i(e)})`,n=this.config.ldap_top_organization||this.config.ldap_base||"",a=await this.server.ldap.system.search({paged:!1,filter:s,attributes:["dn"],scope:"sub"},n);if(a.searchEntries)for(const e of a.searchEntries){const s="string"==typeof e.dn?e.dn:String(e.dn);t.set(s,{read:!0,write:!0,delete:!0})}}catch(s){this.logger.error(`Failed to refresh permissions for user ${e}: ${s}`)}return s===this.permissionsGeneration&&this.permissionsCache.set(e,{branches:t,timestamp:Date.now()}),t}async getAuthorizedBranches(e){const s=Date.now(),t=this.permissionsCache.get(e);return!t||s-t.timestamp>=this.cacheTTL?Array.from((await this.refreshUserPermissions(e)).keys()):Array.from(t.branches.keys())}ruleBranches(e){return this.getAuthorizedBranches(e)}findBranchPermissions(e,t){return s(e.entries(),t)}}export{AuthzLinid1 as default};
@@ -1 +1 @@
1
- import{randomUUID as e}from"crypto";import o from"express-openid-connect";import t from"../../lib/auth/base.js";import{forbidden as s,serverError as i,unauthorized as n}from"../../lib/expressFormatedResponses.js";import{openidconnectOptions as r}from"./auth.options.js";const{auth:a,requiresAuth:c}=o,d=["/login","/logout","/callback","/backchannel-logout"],h=/end_session_endpoint/,u=/^\/logout\/?$/i,l="dmSession",sessionClaims=e=>{const{oidc:o,appSession:t}=e,s=o?.idTokenClaims;if(!s)return;const i=t?.[l];return"string"==typeof i?{...s,session:i}:s},subjectOf=e=>{try{const o=JSON.parse(Buffer.from(String(e).split(".")[1],"base64url").toString());return"string"==typeof o.sub?o.sub:void 0}catch{return}};class OpenIDConnect extends t{static optionDefinitions=r;name="openidconnect";configApiKey="openidconnect";roles=["auth","configurable"];router;warnedNoEndSession=!1;warnedNoRecord=!1;warnedUnnamed=!1;constructor(e){super(e);for(const e of["oidc_server","oidc_client_id","oidc_client_secret","base_url"])if(!this.config[e])throw new Error(`Missing config parameter ${e}`);for(const e of this.options(r).oidc_logout_origins)try{new URL(e)}catch{throw new Error(`${this.name}: --oidc-logout-origins holds "${e}", which is not a URL`)}}buildConfig(){const e=this.options(r);return{authRequired:!1,issuerBaseURL:e.oidc_server,clientID:e.oidc_client_id,secret:e.oidc_client_secret,baseURL:e.base_url,clientSecret:e.oidc_client_secret,authorizationParams:{response_type:"code",scope:"openid profile email"},idpLogout:!0,routes:{logout:!1},afterCallback:async(e,o,t)=>({...t,[l]:await this.sessionId(e,subjectOf(t.id_token))}),backchannelLogout:{onLogoutToken:async e=>{this.server.hookBus.has("oidclogouttoken")?await this.hookBus.notifyStrict("oidclogouttoken",e):this.logger.warn("openidconnect: a logout token arrived but no Back-Channel Logout plugin is loaded, so nothing records it")},onLogin:async e=>{if(!this.server.hookBus.has("oidclogin"))return;const o=e.oidc?.idTokenClaims;if(o)try{await this.hookBus.notifyStrict("oidclogin",o)}catch(o){const t=e.appSession;if(t)for(const e of Object.keys(t))delete t[e];throw o}},isLoggedOut:e=>this.loggedOut(e)}}}identitySource(){const e=this.options(r).oidc_username_claim;return`req.user: the OIDC sub, an opaque provider identifier; req.userName: the "${e}" claim`+("sub"===e?" — the same value, until --oidc-username-claim names another":"")}getConfigApiData(){return{enabled:!0,endpoints:{logout:"/logout"}}}get pathPrefixes(){const e=super.pathPrefixes;return 0===e.length?e:[...e,...d]}api(e){this.router=a(this.buildConfig()),super.api(e)}logoutOrigins(){const{base_url:e,oidc_logout_origins:o}=this.options(r),t=new Set;for(const s of[e,...o])try{t.add(new URL(s).origin)}catch{}return[...t]}crossSite(e){const o=e.headers["sec-fetch-site"];if("same-origin"===o||"none"===o)return!1;const t=this.logoutOrigins();let s=!1;for(const o of["origin","referer"]){const i=e.headers[o];if("string"==typeof i){s=!0;try{if(t.includes(new URL(i).origin))return!1}catch{return!0}}}return"string"==typeof o||s}authMethod(e,o,t){return("GET"===e.method||"HEAD"===e.method)&&u.test(e.path)&&this.crossSite(e)?s(o,"Cross-site logout refused"):this.router?void this.router(e,o,s=>{if(s&&u.test(e.path)&&this.noEndSession(s))return o.redirect(this.options(r).base_url);if(s)return i(o,s);if(("GET"===e.method||"HEAD"===e.method)&&u.test(e.path))return void this.endHere(e).then(()=>o.oidc.logout({logoutParams:{post_logout_redirect_uri:void 0}}));const guard=()=>{c()(e,o,s=>{if(s)return n(o);const i=e.oidc.user,a=this.options(r).oidc_username_claim,c=i[a];"sub"!==a&&"string"!=typeof c&&this.logger.warn(`${this.name}: no "${a}" claim on this session, so req.userName falls back to the sub. Check --oidc-username-claim against the scopes the provider is asked for`),this.publishIdentity(e,String(i.sub),"string"==typeof c?c:String(i.sub)),t()})},a=this.renewExpired(e);a?a.then(guard):guard()}):i(o,new Error(`${this.name}: api() has not built the router`))}renewExpired(e){const o=e.oidc,t=o.accessToken;if(!t?.isExpired())return;const end=()=>{e.appSession=void 0};if(o.refreshToken)return t.refresh().then(()=>{},e=>{this.logger.info(`${this.name}: the provider refused to renew an access token, so the session ends: ${String(e)}`),end()});end()}async loggedOut(e){if(!this.server.hookBus.has("oidcsessionvalid"))return!1;const o=sessionClaims(e),[,t]=await this.hookBus.chain("oidcsessionvalid",[o,!0]);return!t}async sessionId(o,t){const{oidc:s,appSession:i}=o,n=i?.[l];return"string"==typeof n&&void 0!==t&&s?.isAuthenticated()&&s.idTokenClaims?.sub===t&&!await this.loggedOut(o).catch(()=>!0)?n:e()}async endHere(e){if(!e.oidc?.isAuthenticated())return;const o=sessionClaims(e);if(!o)return;const{iss:t,sid:s,session:i}=o,n=i?{iss:t,session:i}:s?{iss:t,sid:s}:void 0;if(n)if(this.server.hookBus.has("oidclogouttoken"))try{await this.hookBus.notifyStrict("oidclogouttoken",n)}catch(e){this.logger.error(`${this.name}: /logout could not record the session it ends, so a copy of its cookie stays valid until its tokens expire: ${String(e)}`)}else this.warnedNoRecord||(this.warnedNoRecord=!0,this.logger.warn(`${this.name}: no Back-Channel Logout plugin is loaded, so /logout drops the browser's cookie and nothing else: a copy of it stays valid until its tokens expire. Load core/bcl to end the session here as well`));else this.warnedUnnamed||(this.warnedUnnamed=!0,this.logger.warn(`${this.name}: /logout ended a session nothing names — one established before this version, from a provider that sends no sid — so a copy of its cookie stays valid until its tokens expire`))}noEndSession(e){return!!(e instanceof Error&&h.test(e.message))&&(this.warnedNoEndSession||(this.warnedNoEndSession=!0,this.logger.warn(`${this.name}: the provider publishes no end_session_endpoint, so /logout ends the session here only, and the provider may log the caller back in`)),!0)}}export{OpenIDConnect as default};
1
+ import{randomUUID as e}from"crypto";import o from"express-openid-connect";import s from"../../lib/auth/base.js";import{forbidden as t,serverError as i,unauthorized as n}from"../../lib/expressFormatedResponses.js";import{openidconnectOptions as r}from"./auth.options.js";const{auth:a,requiresAuth:c}=o,d=["/login","/logout","/callback","/backchannel-logout"],h=/end_session_endpoint/,u=/^\/logout\/?$/i,l="dmSession",sessionClaims=e=>{const{oidc:o,appSession:s}=e,t=o?.idTokenClaims;if(!t)return;const i=s?.[l];return"string"==typeof i?{...t,session:i}:t},subjectOf=e=>{try{const o=JSON.parse(Buffer.from(String(e).split(".")[1],"base64url").toString());return"string"==typeof o.sub?o.sub:void 0}catch{return}};class OpenIDConnect extends s{static optionDefinitions=r;name="openidconnect";configApiKey="openidconnect";roles=["auth","configurable"];router;warnedNoEndSession=!1;warnedNoRecord=!1;warnedUnnamed=!1;constructor(e){super(e);for(const e of["oidc_server","oidc_client_id","oidc_client_secret","base_url"])if(!this.config[e])throw new Error(`Missing config parameter ${e}`);const o=this.options(r),s=o.oidc_session_secret;if(0===s.length)throw new Error("Missing config parameter oidc_session_secret");const t=o.oidc_client_secret;for(const e of s){if(t.includes(e))throw new Error("oidc_session_secret must differ from oidc_client_secret: the session cookie key must not be the client secret nor a fragment of it");if(e.length<32)throw new Error("oidc_session_secret must be at least 32 characters: use `openssl rand -hex 32`, which neither comma nor semicolon splits when the value comes from DM_OIDC_SESSION_SECRET")}for(const e of o.oidc_logout_origins)try{new URL(e)}catch{throw new Error(`${this.name}: --oidc-logout-origins holds "${e}", which is not a URL`)}}buildConfig(){const e=this.options(r),o=e.oidc_session_secret;return{authRequired:!1,issuerBaseURL:e.oidc_server,clientID:e.oidc_client_id,secret:1===o.length?o[0]:o,baseURL:e.base_url,clientSecret:e.oidc_client_secret,authorizationParams:{response_type:"code",scope:"openid profile email"},idpLogout:!0,routes:{logout:!1},afterCallback:async(e,o,s)=>({...s,[l]:await this.sessionId(e,subjectOf(s.id_token))}),backchannelLogout:{onLogoutToken:async e=>{this.server.hookBus.has("oidclogouttoken")?await this.hookBus.notifyStrict("oidclogouttoken",e):this.logger.warn("openidconnect: a logout token arrived but no Back-Channel Logout plugin is loaded, so nothing records it")},onLogin:async e=>{if(!this.server.hookBus.has("oidclogin"))return;const o=e.oidc?.idTokenClaims;if(o)try{await this.hookBus.notifyStrict("oidclogin",o)}catch(o){const s=e.appSession;if(s)for(const e of Object.keys(s))delete s[e];throw o}},isLoggedOut:e=>this.loggedOut(e)}}}identitySource(){const e=this.options(r).oidc_username_claim;return`req.user: the OIDC sub, an opaque provider identifier; req.userName: the "${e}" claim`+("sub"===e?" — the same value, until --oidc-username-claim names another":"")}getConfigApiData(){return{enabled:!0,endpoints:{logout:"/logout"}}}get pathPrefixes(){const e=super.pathPrefixes;return 0===e.length?e:[...e,...d]}api(e){this.router=a(this.buildConfig()),super.api(e)}logoutOrigins(){const{base_url:e,oidc_logout_origins:o}=this.options(r),s=new Set;for(const t of[e,...o])try{s.add(new URL(t).origin)}catch{}return[...s]}crossSite(e){const o=e.headers["sec-fetch-site"];if("same-origin"===o||"none"===o)return!1;const s=this.logoutOrigins();let t=!1;for(const o of["origin","referer"]){const i=e.headers[o];if("string"==typeof i){t=!0;try{if(s.includes(new URL(i).origin))return!1}catch{return!0}}}return"string"==typeof o||t}authMethod(e,o,s){return("GET"===e.method||"HEAD"===e.method)&&u.test(e.path)&&this.crossSite(e)?t(o,"Cross-site logout refused"):this.router?void this.router(e,o,t=>{if(t&&u.test(e.path)&&this.noEndSession(t))return o.redirect(this.options(r).base_url);if(t)return i(o,t);if(("GET"===e.method||"HEAD"===e.method)&&u.test(e.path))return void this.endHere(e).then(()=>o.oidc.logout({logoutParams:{post_logout_redirect_uri:void 0}}));const guard=()=>{c()(e,o,t=>{if(t)return n(o);const i=e.oidc.user,a=this.options(r).oidc_username_claim,c=i[a];"sub"!==a&&"string"!=typeof c&&this.logger.warn(`${this.name}: no "${a}" claim on this session, so req.userName falls back to the sub. Check --oidc-username-claim against the scopes the provider is asked for`),this.publishIdentity(e,String(i.sub),"string"==typeof c?c:String(i.sub)),s()})},a=this.renewExpired(e);a?a.then(guard):guard()}):i(o,new Error(`${this.name}: api() has not built the router`))}renewExpired(e){const o=e.oidc,s=o.accessToken;if(!s?.isExpired())return;const end=()=>{e.appSession=void 0};if(o.refreshToken)return s.refresh().then(()=>{},e=>{this.logger.info(`${this.name}: the provider refused to renew an access token, so the session ends: ${String(e)}`),end()});end()}async loggedOut(e){if(!this.server.hookBus.has("oidcsessionvalid"))return!1;const o=sessionClaims(e),[,s]=await this.hookBus.chain("oidcsessionvalid",[o,!0]);return!s}async sessionId(o,s){const{oidc:t,appSession:i}=o,n=i?.[l];return"string"==typeof n&&void 0!==s&&t?.isAuthenticated()&&t.idTokenClaims?.sub===s&&!await this.loggedOut(o).catch(()=>!0)?n:e()}async endHere(e){if(!e.oidc?.isAuthenticated())return;const o=sessionClaims(e);if(!o)return;const{iss:s,sid:t,session:i}=o,n=i?{iss:s,session:i}:t?{iss:s,sid:t}:void 0;if(n)if(this.server.hookBus.has("oidclogouttoken"))try{await this.hookBus.notifyStrict("oidclogouttoken",n)}catch(e){this.logger.error(`${this.name}: /logout could not record the session it ends, so a copy of its cookie stays valid until its tokens expire: ${String(e)}`)}else this.warnedNoRecord||(this.warnedNoRecord=!0,this.logger.warn(`${this.name}: no Back-Channel Logout plugin is loaded, so /logout drops the browser's cookie and nothing else: a copy of it stays valid until its tokens expire. Load core/bcl to end the session here as well`));else this.warnedUnnamed||(this.warnedUnnamed=!0,this.logger.warn(`${this.name}: /logout ended a session nothing names — one established before this version, from a provider that sends no sid — so a copy of its cookie stays valid until its tokens expire`))}noEndSession(e){return!!(e instanceof Error&&h.test(e.message))&&(this.warnedNoEndSession||(this.warnedNoEndSession=!0,this.logger.warn(`${this.name}: the provider publishes no end_session_endpoint, so /logout ends the session here only, and the provider may log the caller back in`)),!0)}}export{OpenIDConnect as default};
@@ -0,0 +1 @@
1
+ export{ScimIdParams,ScimNameParams,default,scimBodyParser}from"./scim/scim.js";export{writeScimErrorFromException}from"./scim/errors.js";
@@ -1 +1 @@
1
- import{TwakePlugin as e}from"../../abstract/twakePlugin.js";import{isDnInBranch as t,rdnValue as r}from"../../lib/utils.js";import{calendarOptions as s,calendarOptionsOf as a}from"./calendar.options.js";function isRegisteredUser(e){return"object"==typeof e&&null!==e&&"string"==typeof e.id}class Calendar extends e{static optionDefinitions=s;name="calendar";roles=["consistency"];dependencies={onLdapChange:"core/ldap/onChange"};resourceBase;resourceObjectClass;resourceCreator;resourceDomain;firstnameAttr;lastnameAttr;constructor(e){const r=a(e.config);super(e,{url:r.calendar_webadmin_url,token:r.calendar_webadmin_token,concurrency:r.calendar_concurrency,urlOption:"calendar_webadmin_url",tokenOption:"calendar_webadmin_token",concurrencyOption:"calendar_concurrency"}),this.resourceBase=r.calendar_resource_base,this.resourceObjectClass=r.calendar_resource_objectclass,this.resourceCreator=r.calendar_resource_creator,this.resourceDomain=r.calendar_resource_domain;const s=this.config.ldap_base;this.resourceBase&&s&&!t(this.resourceBase,s)&&this.logger.warn(`Calendar plugin: calendar_resource_base (${this.resourceBase}) is not a DN under ${s}; no entry will be taken for a resource`),this.firstnameAttr=r.calendar_firstname_attribute,this.lastnameAttr=r.calendar_lastname_attribute}hooks={ldapcalendarResourceadddone:async(e,t)=>{if(!this.isResource(e,t))return;const r=this.buildResourceData(e,t);r?await this.callWebAdminApi("ldapcalendarResourceadddone",`${this.webadminUrl}/resources`,"POST",e,JSON.stringify(r),{resourceName:r.name}):this.logger.debug(`Skipping resource creation for ${e}: missing required fields`)},ldapcalendarResourcemodifydone:async(e,t,r)=>{if(!this.isInResourceBranch(e))return;const s=this.getResourceId(e);if(!s)return;const a={};t.replace?.cn&&(a.name=Array.isArray(t.replace.cn)?String(t.replace.cn[0]):String(t.replace.cn)),t.replace?.description&&(a.description=Array.isArray(t.replace.description)?String(t.replace.description[0]):String(t.replace.description)),0!==Object.keys(a).length?await this.callWebAdminApi("ldapcalendarResourcemodifydone",this.resourceUrl(s),"PATCH",e,JSON.stringify(a),{resourceId:s,...a}):this.logger.debug(`No relevant changes for resource ${e}`)},ldapcalendarResourcedeletedone:async e=>{if(!this.isInResourceBranch(e))return;const t=this.getResourceId(e);t&&await this.callWebAdminApi("ldapcalendarResourcedeletedone",this.resourceUrl(t),"DELETE",e,null,{resourceId:t})},onLdapChange:async(e,t)=>{const r=t[this.mailAttr];if(r){const t=this.attributeToString(r[0]),s=this.attributeToString(r[1]);return void(t&&s&&t!==s&&await this.syncRegisteredUser("onLdapMailChange",e,t))}(t[this.firstnameAttr]||t[this.lastnameAttr])&&await this.syncRegisteredUser("onLdapDisplayNameChange",e)}};isResource(e,t){if(!this.isInResourceBranch(e))return!1;const r=t.objectClass;if(this.resourceObjectClass){return(Array.isArray(r)?r:[r]).some(e=>String(e).toLowerCase()===this.resourceObjectClass.toLowerCase())}return!0}isInResourceBranch(e){return!this.resourceBase||t(e,this.resourceBase)}resourceUrl(e){return`${this.webadminUrl}/resources/${encodeURIComponent(e)}`}buildResourceData(e,t){const r=t.cn,s=this.attributeToString(r);if(!s)return null;const a=this.attributeToString(t.description)||void 0,i=this.resourceCreator,n=this.resourceDomain||this.extractDomainFromDn(e),o=this.getResourceId(e);return o?{name:s,description:a,creator:i,domain:n,id:o}:null}getResourceId(e){return r(e)||null}async findRegisteredUser(e,t){const r={...t,step:"find_registered_user",searchEmail:e},s=new URL(`${this.webadminUrl}/registeredUsers`);s.searchParams.set("email",e);const a=await this.requestLimit(()=>this.fetch(s.toString(),{method:"GET",headers:this.createHeaders()}));if(404!==a.status){if(!a.ok)return this.logger.error({...r,http_status:a.status,http_status_text:a.statusText}),null;let t;try{t=await a.json()}catch(e){return this.logger.error({...r,http_status:a.status,error:`unreadable answer: ${e}`}),null}if(!Array.isArray(t))return isRegisteredUser(t)?t:(this.logger.error({...r,http_status:a.status,error:"answer is not a registered user"}),null);const s=t.find(t=>isRegisteredUser(t)&&t.email?.toLowerCase()===e.toLowerCase());if(s)return s}return this.logger.warn({...r,message:"user not registered in Calendar"}),null}async syncRegisteredUser(e,t,r){const s={plugin:this.name,event:e,result:"error",dn:t},a=await this.ldapGetAttributes(t,[this.mailAttr,this.firstnameAttr,this.lastnameAttr]);if(!a)return void this.logger.warn({...s,message:`Cannot sync registered user: entry not found for ${t}`});const i=this.attributeToString(a[this.mailAttr]);if(!i)return void this.logger.warn({...s,message:`Cannot sync registered user: no mail found for ${t}`});const n=this.attributeToString(a[this.firstnameAttr]),o=this.attributeToString(a[this.lastnameAttr]),c=r||i;try{const e=await this.findRegisteredUser(c,s);if(!e)return;const t=new URL(`${this.webadminUrl}/registeredUsers`);t.searchParams.set("id",e.id);const r={email:i};n&&(r.firstname=n),o&&(r.lastname=o);const a=await this.requestLimit(()=>this.fetch(t.toString(),{method:"PATCH",headers:this.createHeaders("application/json"),body:JSON.stringify(r)}));a.ok?this.logger.info({...s,result:"success",id:e.id,http_status:a.status,...r}):this.logger.error({...s,step:"patch_registered_user",id:e.id,http_status:a.status,http_status_text:a.statusText})}catch(e){this.logger.error({...s,error:`${e}`})}}async deleteUserData(e){const t={plugin:this.name,event:"deleteUserData",mail:e};try{const r=new URL(`${this.webadminUrl}/users/${e}`);r.searchParams.set("action","deleteData");const s=await this.requestLimit(()=>this.fetch(r.toString(),{method:"POST",headers:this.createHeaders()}));if(!s.ok)return this.logger.error({...t,http_status:s.status,http_status_text:s.statusText}),null;const a=await s.json();return this.logger.info({...t,http_status:s.status,taskId:a.taskId}),a}catch(e){return this.logger.error({...t,error:`${e}`}),null}}}export{Calendar as default};
1
+ import{TwakePlugin as e}from"../../abstract/twakePlugin.js";import{isDnInBranch as t,rdnValue as r}from"../../lib/utils.js";import{calendarOptions as s,calendarOptionsOf as a}from"./calendar.options.js";function isRegisteredUser(e){return"object"==typeof e&&null!==e&&"string"==typeof e.id}class Calendar extends e{static optionDefinitions=s;name="calendar";roles=["consistency"];dependencies={onLdapChange:"core/ldap/onChange"};resourceBase;resourceObjectClass;resourceCreator;resourceDomain;firstnameAttr;lastnameAttr;constructor(e){const r=a(e.config);super(e,{url:r.calendar_webadmin_url,token:r.calendar_webadmin_token,concurrency:r.calendar_concurrency,urlOption:"calendar_webadmin_url",tokenOption:"calendar_webadmin_token",concurrencyOption:"calendar_concurrency"}),this.resourceBase=r.calendar_resource_base,this.resourceObjectClass=r.calendar_resource_objectclass,this.resourceCreator=r.calendar_resource_creator,this.resourceDomain=r.calendar_resource_domain;const s=this.config.ldap_base;this.resourceBase&&s&&!t(this.resourceBase,s)&&this.logger.warn(`Calendar plugin: calendar_resource_base (${this.resourceBase}) is not a DN under ${s}; no entry will be taken for a resource`),this.firstnameAttr=r.calendar_firstname_attribute,this.lastnameAttr=r.calendar_lastname_attribute}hooks={ldapcalendarResourceadddone:async(e,t)=>{if(!this.isResource(e,t))return;const r=this.buildResourceData(e,t);r?await this.callWebAdminApi("ldapcalendarResourceadddone",`${this.webadminUrl}/resources`,"POST",e,JSON.stringify(r),{resourceName:r.name}):this.logger.debug(`Skipping resource creation for ${e}: missing required fields`)},ldapcalendarResourcemodifydone:async(e,t,r)=>{if(!this.isInResourceBranch(e))return;const s=this.getResourceId(e);if(!s)return;const a={};t.replace?.cn&&(a.name=Array.isArray(t.replace.cn)?String(t.replace.cn[0]):String(t.replace.cn)),t.replace?.description&&(a.description=Array.isArray(t.replace.description)?String(t.replace.description[0]):String(t.replace.description)),0!==Object.keys(a).length?await this.callWebAdminApi("ldapcalendarResourcemodifydone",this.resourceUrl(s),"PATCH",e,JSON.stringify(a),{resourceId:s,...a}):this.logger.debug(`No relevant changes for resource ${e}`)},ldapcalendarResourcedeletedone:async e=>{if(!this.isInResourceBranch(e))return;const t=this.getResourceId(e);t&&await this.callWebAdminApi("ldapcalendarResourcedeletedone",this.resourceUrl(t),"DELETE",e,null,{resourceId:t})},onLdapChange:async(e,t)=>{const r=t[this.mailAttr];if(r){const t=this.attributeToString(r[0]),s=this.attributeToString(r[1]);return void(t&&s&&t!==s&&await this.syncRegisteredUser("onLdapMailChange",e,t))}(t[this.firstnameAttr]||t[this.lastnameAttr])&&await this.syncRegisteredUser("onLdapDisplayNameChange",e)}};isResource(e,t){if(!this.isInResourceBranch(e))return!1;const r=t.objectClass;if(this.resourceObjectClass){return(Array.isArray(r)?r:[r]).some(e=>String(e).toLowerCase()===this.resourceObjectClass.toLowerCase())}return!0}isInResourceBranch(e){return!this.resourceBase||t(e,this.resourceBase)}resourceUrl(e){return this.webadminPath("resources",e)}buildResourceData(e,t){const r=t.cn,s=this.attributeToString(r);if(!s)return null;const a=this.attributeToString(t.description)||void 0,i=this.resourceCreator,n=this.resourceDomain||this.extractDomainFromDn(e),o=this.getResourceId(e);return o?{name:s,description:a,creator:i,domain:n,id:o}:null}getResourceId(e){return r(e)||null}async findRegisteredUser(e,t){const r={...t,step:"find_registered_user",searchEmail:e},s=new URL(`${this.webadminUrl}/registeredUsers`);s.searchParams.set("email",e);const a=await this.requestLimit(()=>this.fetch(s.toString(),{method:"GET",headers:this.createHeaders()}));if(404!==a.status){if(!a.ok)return this.logger.error({...r,http_status:a.status,http_status_text:a.statusText}),null;let t;try{t=await a.json()}catch(e){return this.logger.error({...r,http_status:a.status,error:`unreadable answer: ${e}`}),null}if(!Array.isArray(t))return isRegisteredUser(t)?t:(this.logger.error({...r,http_status:a.status,error:"answer is not a registered user"}),null);const s=t.find(t=>isRegisteredUser(t)&&t.email?.toLowerCase()===e.toLowerCase());if(s)return s}return this.logger.warn({...r,message:"user not registered in Calendar"}),null}async syncRegisteredUser(e,t,r){const s={plugin:this.name,event:e,result:"error",dn:t},a=await this.ldapGetAttributes(t,[this.mailAttr,this.firstnameAttr,this.lastnameAttr]);if(!a)return void this.logger.warn({...s,message:`Cannot sync registered user: entry not found for ${t}`});const i=this.attributeToString(a[this.mailAttr]);if(!i)return void this.logger.warn({...s,message:`Cannot sync registered user: no mail found for ${t}`});const n=this.attributeToString(a[this.firstnameAttr]),o=this.attributeToString(a[this.lastnameAttr]),c=r||i;try{const e=await this.findRegisteredUser(c,s);if(!e)return;const t=new URL(`${this.webadminUrl}/registeredUsers`);t.searchParams.set("id",e.id);const r={email:i};n&&(r.firstname=n),o&&(r.lastname=o);const a=await this.requestLimit(()=>this.fetch(t.toString(),{method:"PATCH",headers:this.createHeaders("application/json"),body:JSON.stringify(r)}));a.ok?this.logger.info({...s,result:"success",id:e.id,http_status:a.status,...r}):this.logger.error({...s,step:"patch_registered_user",id:e.id,http_status:a.status,http_status_text:a.statusText})}catch(e){this.logger.error({...s,error:`${e}`})}}async deleteUserData(e){const t={plugin:this.name,event:"deleteUserData",mail:e};try{const r=new URL(this.webadminPath("users",e));r.searchParams.set("action","deleteData");const s=await this.requestLimit(()=>this.fetch(r.toString(),{method:"POST",headers:this.createHeaders()}));if(!s.ok)return this.logger.error({...t,http_status:s.status,http_status_text:s.statusText}),null;const a=await s.json();return this.logger.info({...t,http_status:s.status,taskId:a.taskId}),a}catch(e){return this.logger.error({...t,error:`${e}`}),null}}}export{Calendar as default};
@@ -1 +1 @@
1
- import{Type as t}from"@sinclair/typebox";import{TwakePlugin as e}from"../../abstract/twakePlugin.js";import"../../abstract/plugin.js";import{ConflictError as a}from"../../lib/errors.js";import{extractLdapCode as i}from"../../lib/ldapCodes.js";import{currentOperation as s}from"../../lib/operation.js";import{wantJson as r}from"../../lib/expressFormatedResponses.js";import{roleAttribute as n}from"../../config/schema.js";import{domainBranches as o,mailDomains as l}from"../../lib/mailDomains.js";import{isDnInBranch as m,parseDn as d,escapeLdapFilter as u,normalizeDn as h,isDummyMemberDn as c}from"../../lib/utils.js";import{jamesOptions as g,jamesOptionsOf as p}from"./james.options.js";import{defineRoute as f}from"../../lib/route.js";const b=[400,404],w=[500,502,503,504],pickDefaultIdentity=t=>(Array.isArray(t)?t:[t]).filter(t=>t&&t.id&&!1!==t.mayDelete).sort((t,e)=>(t.sortOrder??0)-(e.sortOrder??0))[0],members=t=>valuesOf(t??void 0,"member")??[],sameAttr=(t,e)=>t.split(";")[0].toLowerCase()===e.split(";")[0].toLowerCase(),valuesOf=(t,e)=>{if(!t)return;const a=Object.keys(t).filter(t=>sameAttr(t,e));return a.length?a.flatMap(e=>{return null==(a=t[e])?[]:(Array.isArray(a)?a:[a]).map(String);var a}):void 0},valuesAfter=(t,e,a,i)=>{let s=valuesOf(t,a)??[];const r=valuesOf(e.add,a);r&&(s=[...s,...r]);const n=valuesOf(e.replace,a);n&&(s=n);const o=e.delete;if(Array.isArray(o))o.some(t=>sameAttr(t,a))&&(s=[]);else{const t=valuesOf(o,a);if(t){const e=new Set(t.map(i));s=e.size?s.filter(t=>!e.has(i(t))):[]}}return s},memberKey=t=>{try{return h(t)}catch{return t.toLowerCase()}},y=["groupofnames","groupofuniquenames","groupofurls"],isGroup=t=>(Array.isArray(t)?t:t?[t]:[]).some(t=>y.includes(String(t).toLowerCase())),sameDn=(t,e)=>{try{return h(t)===h(e)}catch{return!1}},A=t.Object({user:t.String({minLength:1})});class James extends e{static optionDefinitions=g;name="james";roles=["consistency","api","configurable"];dependencies={onLdapChange:"core/ldap/onChange",ldapGroups:"core/ldap/groups"};health(){return this.webadminHealth()}quotaAttr;aliasAttr;mailboxTypeAttr;forwardAttr;initDelay;constructor(t){const e=p(t.config);super(t,{url:e.james_webadmin_url,token:e.james_webadmin_token,concurrency:e.james_concurrency,urlOption:"james_webadmin_url",tokenOption:"james_webadmin_token",concurrencyOption:"james_concurrency"}),this.quotaAttr=this.config.quota_attribute||"",this.aliasAttr=this.config.alias_attribute||"mailAlternateAddress",this.mailboxTypeAttr=e.james_mailbox_type_attribute,this.forwardAttr=this.config.forward_attribute||"mailForwardingAddress",this.initDelay=e.james_init_delay,this.retryAttempts=Math.max(1,e.james_retry_attempts),this.retryDelay=Math.max(0,e.james_retry_delay)}get settings(){return this.options(g)}api(t){const e=this.config.api_prefix||"/api";f(t,{method:"get",path:`${e}/v1/users/:user/quota-usage`,params:A,handler:async({req:t,res:e})=>this.getUserQuota(t,e)}),this.logger.info(`James API registered at ${e}/v1/users/:user/quota-usage`)}async getUserQuota(t,e){if(!r(t,e))return;const a=t.params.user;try{const i=await this.findReadableUser(a,t,[this.mailAttr]);if(!i)return void e.status(404).json({error:`User ${a} not found`});const s=i[this.mailAttr];if(!s)return void e.status(400).json({error:`User ${a} has no mail attribute`});const r=Array.isArray(s)?String(s[0]):String(s),n=`${this.webadminUrl}/quota/users/${r}`,o=await this.requestLimit(()=>this.fetch(n,{method:"GET",headers:this.createHeaders()}));if(!o.ok)return void(404===o.status?e.status(404).json({error:`Quota not found for user ${a}`}):e.status(502).json({error:`Failed to retrieve quota from James: ${o.statusText}`}));const l=await o.json();e.json(l)}catch(t){this.logger.error({plugin:this.name,event:"getUserQuota",username:a,error:t instanceof Error?t.message:String(t)}),e.status(500).json({error:"Internal server error while retrieving quota"})}}hooks={ldapaddrequest:async([t,e,a])=>{const i=this.teamMailboxDomain(valuesOf(e,this.mailAttr),valuesOf(e,this.mailboxTypeAttr));return i&&await this.assertMembersAt(t,i,valuesOf(e,"member")??[]),void 0!==a?[t,e,a]:[t,e]},ldapmodifyrequest:async([t,e,a,i])=>(await this.guardTeamMailboxChange(t,e),await this.guardDomainChange(t,e),[t,e,a,i]),ldapdeleterequest:async([t,e])=>{for(const e of Array.isArray(t)?t:[t])e&&await this.guardDomainRemoval(e,"delete");return[t,e]},ldaprenamerequest:async([t,e,a])=>(await this.guardDomainMove(t,e),[t,e,a]),ldaprenamedone:async(t,e)=>{void 0===s()&&await this.followMove(t,e)},ldapadddone:(t,e)=>this.provisionAccount(t,e),onLdapMailChange:async(t,e,a)=>{const i=e?Array.isArray(e)?String(e[0]):String(e):null,s=a?Array.isArray(a)?String(a[0]):String(a):null;if(!i)return void this.logger.debug(`Skipping mail rename for ${t}: oldmail is empty (mail attribute was added, not changed)`);if(!s)return void this.logger.debug(`Skipping mail rename for ${t}: newmail is empty (mail attribute was deleted, not changed)`);if(await this.isGroupDn(t))return;const r=await this.defaultSignatures(i),n=await this.ldapGetAttributes(t,[this.forwardAttr,this.displayNameAttr,"cn","givenName","sn",this.mailAttr]);await this.dropStaleForwards(t,s,this.ownForwards(n?.[this.forwardAttr])),await this.callWebAdminApi("onLdapMailChange",`${this.webadminUrl}/users/${i}/rename/${s}?action=rename&force`,"POST",t,null,{oldmail:i,newmail:s},b);try{const e=await this.server.ldap.system.search({paged:!1,scope:"base",attributes:[this.aliasAttr]},t);if(e.searchEntries&&e.searchEntries.length>0){const a=this.getAliases(e.searchEntries[0][this.aliasAttr]);a.length>0&&await Promise.all([...a.map(e=>this.callWebAdminApi("onLdapMailChange-delete",`${this.webadminUrl}/address/aliases/${i}/sources/${e}`,"DELETE",t,null,{oldmail:i,alias:e})),...a.map(e=>this.callWebAdminApi("onLdapMailChange-create",`${this.webadminUrl}/address/aliases/${s}/sources/${e}`,"PUT",t,null,{newmail:s,alias:e},b))])}}catch(t){this.logger.debug("Could not fetch aliases for mail change:",t)}const o=n&&this.getDisplayNameFromAttributes(n);o&&await this.updateJamesIdentity(t,s,o,r)},onLdapAliasChange:async(t,e,a,i)=>{if(await this.isGroupDn(t))return;const s=a.map(t=>this.normalizeAlias(t)),r=i.map(t=>this.normalizeAlias(t)),n=s.filter(t=>!r.includes(t)),o=r.filter(t=>!s.includes(t));await Promise.all([...n.map(a=>this.callWebAdminApi("onLdapAliasChange-delete",`${this.webadminUrl}/address/aliases/${e}/sources/${a}`,"DELETE",t,null,{mail:e,alias:a,action:"delete"})),...o.map(async a=>{await this.dropStaleForwards(t,a,[]),await this.callWebAdminApi("onLdapAliasChange-add",`${this.webadminUrl}/address/aliases/${e}/sources/${a}`,"PUT",t,null,{mail:e,alias:a,action:"add"},b)})])},onLdapQuotaChange:async(t,e,a,i)=>{if(await this.isGroupDn(t))return;const s=i>0;await this.callWebAdminApi("onLdapQuotaChange",`${this.webadminUrl}/quota/users/${e}/size`,s?"PUT":"DELETE",t,s?i.toString():null,{oldQuota:a,newQuota:i},b)},onLdapForwardChange:async(t,e,a,i)=>{if(!this.settings.james_sync_forwards)return;if(await this.isGroupDn(t))return;const s=this.extractMailDomain(e);if(!s)return void this.logger.error(`Cannot extract domain from mail ${e} for forward management`);const r=a.filter(t=>!i.includes(t)),n=i.filter(t=>!a.includes(t));await Promise.all([...r.map(a=>this.callWebAdminApi("onLdapForwardChange-delete",`${this.webadminUrl}/address/forwards/${e}/targets/${a}`,"DELETE",t,null,{mail:e,forward:a,domain:s,action:"delete"})),...n.map(a=>this.callWebAdminApi("onLdapForwardChange-add",`${this.webadminUrl}/address/forwards/${e}/targets/${a}`,"PUT",t,null,{mail:e,forward:a,domain:s,action:"add"},b))])},onLdapChange:async(t,e)=>{this.config.delegation_attribute&&e[this.config.delegation_attribute]&&!await this.isGroupDn(t)&&await this._handleDelegationChange(t,e)},onLdapDisplayNameChange:async t=>{const e=[this.mailAttr,this.displayNameAttr,"cn","givenName","sn","objectClass"],a=await this.ldapGetAttributes(t,e);if(!a)return void this.logger.warn(`Cannot update James identity: entry not found for ${t}`);if(isGroup(a.objectClass))return;const i=this.attributeToString(a[this.mailAttr]);if(!i)return void this.logger.warn(`Cannot update James identity: no mail found for ${t}`);const s=this.getDisplayNameFromAttributes(a);if(s)return this.updateJamesIdentity(t,i,s);this.logger.warn(`Cannot update James identity: no display name found for ${t}`)},onLdapEntryChange:async(t,e,a)=>{e&&a&&!e[this.mailAttr]&&a[this.mailAttr]&&await this.provisionAccount(t,a),await this.followTeamMailbox(e,a),await this.followDomains(e,a)}};domainAttribute(t){let e,a=-1;for(const{base:i,attribute:s}of o(this.server))try{if(sameDn(t,i)||!m(t,i))continue;const r=d(i).length;r>a&&([e,a]=[s,r])}catch{}return e}domainsOf(t){const e=t?.dn&&this.domainAttribute(String(t.dn));return e?l(valuesOf(t,e)):[]}afterLoad(){if(!this.server.loadedPlugins.trash)return;const t=String(this.config.trash_watched_bases??"").split(";").map(t=>t.trim()).filter(Boolean);for(const{base:e}of o(this.server)){const a=t.length?t.find(t=>{try{return m(e,t)||m(t,e)}catch{return!1}}):"every branch";a&&this.logger.warn(`James: the trash watches the mail domains of ${e} (--trash-watched-bases: ${a}): a domain deleted there may skip the check that it is still in use; leave the domain branch out of the trash, see docs/usage/plugins/integrations/james-mail.md#trash`)}}async followDomains(t,e,a=!1){const i=this.domainsOf(t),s=this.domainsOf(e);if(!i.length&&!s.length)return;const r=String((e??t).dn),call=(t,e)=>this.callWebAdminApi("PUT"===e?"domain:create":"domain:delete",`${this.webadminUrl}/domains/${t}`,e,r,null,{domain:t},w);await Promise.all([...s.filter(t=>!i.includes(t)).map(t=>this.inDomainOrder(t,()=>call(t,"PUT"))),...i.filter(t=>!s.includes(t)).map(t=>this.inDomainOrder(t,async()=>{if(await this.domainDeclaredElsewhere(t,r))return;const e=a?await this.addressAt(t):null;e?this.logger.error({plugin:this.name,event:"domain:delete",dn:r,domain:t,result:"skipped",error:`${e} still has an address at ${t}: the domain is kept in James`}):await call(t,"DELETE")}))])}async followMove(t,e){const a=[this.domainAttribute(t),this.domainAttribute(e)].filter(t=>Boolean(t)).filter((t,e,a)=>a.findIndex(e=>sameAttr(e,t))===e);if(!a.length)return;const i=await this.ldapGetAttributes(e,a);i&&await this.followDomains({...i,dn:t},{...i,dn:e},!0)}async inDomainOrder(t,e){const run=async()=>{await e()},a=(this.domainQueue.get(t)??Promise.resolve()).then(run,run);this.domainQueue.set(t,a);try{await a}finally{this.domainQueue.get(t)===a&&this.domainQueue.delete(t)}}domainQueue=new Map;async firstEntry(t,e,a,s){try{const i=await this.server.ldap.system.search({paged:{pageSize:500},scope:"sub",filter:e,attributes:a},t);for await(const t of i)for(const e of t.searchEntries||[])if(s(e))return e;return null}catch(t){if(32===i(t))return null;throw t}}async domainDeclaredElsewhere(t,e){try{for(const{base:a,attribute:i}of o(this.server)){if(await this.firstEntry(a,`(${i}=${u(t)})`,["1.1"],t=>{const a=String(t.dn),s=this.domainAttribute(a);return!sameDn(a,e)&&!!s&&sameAttr(s,i)}))return!0}return!1}catch(a){return this.logger.error({plugin:this.name,event:"domain:delete",dn:e,domain:t,result:"skipped",error:a instanceof Error?a.message:String(a)}),!0}}async guardDomainRemoval(t,e){const i=this.domainAttribute(t);if(!i)return;const s=await this.ldapGetAttributes(t,[i]),r=l(valuesOf(s??void 0,i));if(!r.length)return;const n=await this.domainLinkHolder(t,String(s.dn??t));if(n)throw this.logger.warn(`${e} of ${t} refused: organization ${n} links it`),new a(`Cannot ${e} ${t}: an organization still links mail domain ${r.join(", ")}`);for(const a of r)await this.assertNoAddressAt(t,a,`Cannot ${e} ${t}`)}async guardDomainMove(t,e){const a=this.domainAttribute(t);if(!a)return;const i=this.domainAttribute(e);if(!i)return this.guardDomainRemoval(t,"move");const s=await this.ldapGetAttributes(t,sameAttr(a,i)?[a]:[a,i])??void 0,r=l(valuesOf(s,i));for(const e of l(valuesOf(s,a)))r.includes(e)||await this.assertNoAddressAt(t,e,`Cannot move ${t}`)}async guardDomainChange(t,e){const a=this.domainAttribute(t);if(!a||!((t,e)=>[t.add,t.replace].some(t=>t&&Object.keys(t).some(t=>sameAttr(t,e)))||(Array.isArray(t.delete)?t.delete.some(t=>sameAttr(t,e)):Object.keys(t.delete??{}).some(t=>sameAttr(t,e))))(e,a))return;const i=await this.ldapGetAttributes(t,[a]);if(!i)return;const s=l(valuesAfter(i,e,a,t=>t.toLowerCase()));for(const e of l(valuesOf(i,a)))s.includes(e)||await this.assertNoAddressAt(t,e,`Cannot remove mail domain ${e} from ${t}`)}domainLinkAttributes(){const t=this.server.loadedPlugins.ldapOrganizations,e=this.server.loadedPlugins.ldapFlatGeneric,a=[t?.schema,...(e?.instances||[]).map(t=>t.schema)].map(t=>n(t,"domainLink"));a.push(this.config.enterprise_domain_link_attribute);const i=[];for(const t of a)t&&!i.some(e=>sameAttr(e,t))&&i.push(t);return i}async domainLinkHolder(t,e){const a=this.domainLinkAttributes();if(!a.length)return null;const i=[...new Set([t,e,h(t)])],s=`(|${a.flatMap(t=>i.map(e=>`(${t}=${u(e)})`)).join("")})`,r=await this.firstEntry(this.config.ldap_base||"",s,a,e=>a.some(a=>(valuesOf(e,a)??[]).some(e=>sameDn(e,t))));return r?String(r.dn):null}async addressAt(t){const e=[this.mailAttr,this.aliasAttr],atDomain=e=>this.extractMailDomain(this.normalizeAlias(e.trim()))?.toLowerCase()===t,a=await this.firstEntry(this.config.ldap_base||"",`(|${e.map(t=>`(${t}=*)`).join("")})`,e,t=>e.some(e=>(valuesOf(t,e)??[]).some(atDomain)));return a?String(a.dn):null}async assertNoAddressAt(t,e,i){if(await this.domainDeclaredElsewhere(e,t))return;const s=await this.addressAt(e);if(s)throw this.logger.warn(`${i}: ${s} still has an address at ${e}`),new a(`${i}: addresses at ${e} still exist, and James would refuse their mail`)}teamMailboxOf(t){if(!t||"teamMailbox"!==this.getMailboxType(t))return null;const e=this.attributeToString(t[this.mailAttr]);if(!e)return null;const a=String(t.dn),i=this.settings.james_mailing_list_branch;if(i.some(t=>a===t||a.endsWith(","+t)))return{mail:e,refused:`Team mailbox cannot be in mailing list branches: ${i.join(", ")}`};const s=e.lastIndexOf("@");return/^[#a-zA-Z0-9_-]{1,255}$/.test(e.slice(0,s))&&this.extractMailDomain(e)?{mail:e,refused:null}:{mail:e,refused:"A team mailbox address is a name of letters, digits, #, - and _ at a domain"}}async provisionAccount(t,e){const a=e[this.mailAttr],i=e[this.quotaAttr],s=e[this.aliasAttr];if(!a||isGroup(e.objectClass))return;const r=Array.isArray(a)?String(a[0]):String(a);if(this.initDelay>0&&await new Promise(t=>setTimeout(t,this.initDelay)),i){const e=Array.isArray(i)?Number(i[0]):Number(i);!isNaN(e)&&e>0&&await this.callWebAdminApi("ldapadddone:quota",`${this.webadminUrl}/quota/users/${r}/size`,"PUT",t,e.toString(),{mail:r,quota:e},b)}const n=this.getAliases(s);await Promise.all([this.dropStaleForwards(t,r,this.ownForwards(e[this.forwardAttr])),...n.map(e=>this.dropStaleForwards(t,e,[]))]),n.length>0&&await Promise.all(n.map(e=>this.callWebAdminApi("ldapadddone:alias",`${this.webadminUrl}/address/aliases/${r}/sources/${e}`,"PUT",t,null,{mail:r,alias:e},b)));const o=this.getDisplayNameFromAttributes(e);o&&await this.updateJamesIdentity(t,r,o)}async followTeamMailbox(t,e){const a=this.teamMailboxOf(t),i=this.teamMailboxOf(e);if(!a&&!i)return;const s=String((e??t).dn);i?.refused&&i.refused!==a?.refused&&this.logger.error({plugin:this.name,event:"teamMailbox",dn:s,mail:i.mail,error:i.refused});const r=a&&!a.refused?a.mail:null,n=i&&!i.refused?i.mail:null;if(!r&&!n)return;const o=memberKey(s),follow=async()=>{if(r&&n&&r.toLowerCase()===n.toLowerCase()){const a=new Map(members(t).map(t=>[memberKey(t),t])),i=[];for(const t of members(e))a.delete(memberKey(t))||i.push(t);return void await Promise.all([this.teamMailboxMembers(s,n,"PUT",i),this.teamMailboxMembers(s,n,"DELETE",[...a.values()])])}r&&await this.teamMailboxMembers(s,r,"DELETE",members(t)),n&&await this.createTeamMailbox(s,e,n)},l=(this.teamMailboxQueue.get(o)??Promise.resolve()).then(follow,follow);this.teamMailboxQueue.set(o,l);try{await l}finally{this.teamMailboxQueue.get(o)===l&&this.teamMailboxQueue.delete(o)}}teamMailboxQueue=new Map;getConfigApiData(){return{teamMailboxAnyDomain:this.settings.james_team_mailbox_any_domain}}teamMailboxDomain(t,e){if(this.settings.james_team_mailbox_any_domain||"teamMailbox"!==this.getMailboxType({[this.mailboxTypeAttr]:e}))return null;const a=this.attributeToString(t);return a&&this.extractMailDomain(a)?.toLowerCase()||null}async guardTeamMailboxChange(t,e){if(this.settings.james_team_mailbox_any_domain)return;const a=[this.mailAttr,this.mailboxTypeAttr,"member"];if(![e.add,e.replace].some(t=>t&&Object.keys(t).some(t=>a.some(e=>sameAttr(t,e)))))return;const i=await this.ldapGetAttributes(t,a);if(!i)return;const after=(t,a=t=>t.toLowerCase())=>valuesAfter(i,e,t,a),s=this.teamMailboxDomain(after(this.mailAttr),after(this.mailboxTypeAttr));if(!s)return;const r=new Set((valuesOf(i,"member")??[]).map(memberKey)),n=after("member",memberKey),o=n.filter(t=>!r.has(memberKey(t))),l=s!==this.teamMailboxDomain(valuesOf(i,this.mailAttr),valuesOf(i,this.mailboxTypeAttr));await this.assertMembersAt(t,s,o,l?n.filter(t=>r.has(memberKey(t))):[])}async assertMembersAt(t,e,i,s=[]){const foreign=async t=>(await this.getMemberEmails(t)).filter(t=>this.extractMailDomain(t)?.toLowerCase()!==e),[r,n]=await Promise.all([foreign(i),foreign(s)]);if(0===r.length&&0===n.length)return;const o=r.length?`${r.slice(0,5).join(", ")}${r.length>5?", …":""} `+(r.length>1?"are":"is")+" not":"",l=n.length?"some of the members it holds are not":"";throw new a(`The members of team mailbox ${t} must have an address at ${e}: `+[o,l].filter(Boolean).join(", and "))}teamMailboxUrl(t){const e=t.toLowerCase(),a=e.lastIndexOf("@");return`${this.webadminUrl}/domains/${e.slice(a+1)}/team-mailboxes/${encodeURIComponent(e.slice(0,a))}`}async teamMailboxMembers(t,e,a,i){if(!i.length)return;const s=this.teamMailboxUrl(e),r=await this.getMemberEmails(i);await Promise.all(r.map(i=>this.callWebAdminApi("teamMailbox:"+("PUT"===a?"addMember":"removeMember"),`${s}/members/${i}`,a,t,null,{teamMailbox:e,memberMail:i})))}getDisplayNameFromAttributes(t){const e=this.attributeToString(t[this.displayNameAttr]);if(e)return e;const a=this.attributeToString(t.cn);if(a)return a;const i=this.attributeToString(t.givenName),s=this.attributeToString(t.sn);if(i||s){const t=[];return i&&t.push(i),s&&t.push(s),t.join(" ")}const r=this.attributeToString(t[this.mailAttr]);return r||null}async isGroupDn(t){const e=await this.ldapGetAttributes(t,["objectClass"]);return isGroup(e?.objectClass)}async getMailFromDN(t){const e=await this.ldapGetAttributes(t,[this.mailAttr]);return e?this.attributeToString(e[this.mailAttr]):null}async getDisplayNameFromDN(t){const e=[this.displayNameAttr,"cn","givenName","sn",this.mailAttr],a=await this.ldapGetAttributes(t,e);return a?this.getDisplayNameFromAttributes(a):null}async generateSignature(t){const e=this.settings.james_signature_template;if(!e)return null;const a=await this.ldapGetAttributes(t);if(!a)return null;let i=e;return i=i.replace(/\{(\w+)\}/g,(t,e)=>"string"==typeof e&&a&&"object"==typeof a&&Object.hasOwn(a,e)&&this.attributeToString(a[e])||""),i}async updateJamesIdentity(t,e,a,i={}){const s=(this.identityQueue.get(e)??Promise.resolve()).then(()=>this.doUpdateJamesIdentity(t,e,a,i));this.identityQueue.set(e,s);try{await s}finally{this.identityQueue.get(e)===s&&this.identityQueue.delete(e)}}identityQueue=new Map;async doUpdateJamesIdentity(t,e,a,i){const s={plugin:this.name,event:"onLdapDisplayNameChange",result:"error",dn:t,mail:e,displayName:a};for(let r=1;;r++){try{const n=`${this.webadminUrl}/users/${e}/identities`,o=await this.requestLimit(()=>this.fetch(`${n}?default=true`,{method:"GET",headers:this.createHeaders()}));let l;if(o.ok)l=pickDefaultIdentity(await o.json());else if(404!==o.status){const t="get_default_identity";if(this.shouldRetry(o.status,b,r)){await this.warnAndWait(s,t,r,o);continue}return void this.logger.error({...s,step:t,...r>1?{attempts:r}:{},http_status:o.status,http_status_text:o.statusText})}const m=await this.generateSignature(t),d=l?.id?{id:l.id,email:l.email,name:a}:{email:e,name:a,sortOrder:0};m?d.htmlSignature=m:Object.assign(d,i);const u=l?.id?"update_identity":"create_identity",h=await this.requestLimit(()=>this.fetch(l?.id?`${n}/${l.id}`:n,{method:l?.id?"PUT":"POST",headers:this.createHeaders("application/json"),body:JSON.stringify(d)}));if(h.ok)this.logger.info({...s,step:u,result:"success",http_status:h.status});else{if(this.shouldRetry(h.status,b,r)){await this.warnAndWait(s,u,r,h);continue}this.logger.error({...s,step:u,...r>1?{attempts:r}:{},http_status:h.status,http_status_text:h.statusText})}}catch(t){this.logger.error({...s,error:`${t}`})}return}}async defaultSignatures(t){try{const e=await this.requestLimit(()=>this.fetch(`${this.webadminUrl}/users/${t}/identities?default=true`,{method:"GET",headers:this.createHeaders()}));if(!e.ok)return{};const a=pickDefaultIdentity(await e.json()),i={};return a?.htmlSignature&&(i.htmlSignature=a.htmlSignature),a?.textSignature&&(i.textSignature=a.textSignature),i}catch(e){return this.logger.debug(`Could not read the identities of ${t}: ${e}`),{}}}ownForwards(t){return this.settings.james_sync_forwards?this.getAliases(t):[]}async dropStaleForwards(t,e,a){let i;try{const s=await this.requestLimit(()=>this.fetch(`${this.webadminUrl}/address/forwards/${e}`,{method:"GET",headers:this.createHeaders()}));if(!s.ok)return void(404!==s.status&&this.logger.warn({plugin:this.name,event:"dropStaleForward",result:"error",dn:t,address:e,step:"list_forwards",http_status:s.status,http_status_text:s.statusText}));const r=a.map(t=>t.toLowerCase());i=(await s.json()).map(t=>t.mailAddress).filter(t=>t&&!r.includes(t.toLowerCase()))}catch(t){return void this.logger.debug(`Could not read the forwards of ${e}: ${t}`)}await Promise.all(i.map(a=>this.callWebAdminApi("dropStaleForward",`${this.webadminUrl}/address/forwards/${e}/targets/${a}`,"DELETE",t,null,{address:e,forward:a})))}async warnAndWait(t,e,a,i){this.logger.warn({...t,step:e,result:"retry",attempt:a,http_status:i.status,http_status_text:i.statusText}),await this.waitBeforeRetry(a)}async deleteUserData(t,e){const a={plugin:this.name,event:"deleteUserData",mail:t,fromStep:e};try{const i=new URL(`${this.webadminUrl}/users/${t}`);i.searchParams.set("action","deleteData"),e&&i.searchParams.set("fromStep",e);const s=await this.requestLimit(()=>this.fetch(i.toString(),{method:"POST",headers:this.createHeaders()}));if(!s.ok)return this.logger.error({...a,http_status:s.status,http_status_text:s.statusText}),null;const r=await s.json();return this.logger.info({...a,http_status:s.status,taskId:r.taskId}),r}catch(t){return this.logger.error({...a,error:`${t}`}),null}}getMailboxType(t){const e=t[this.mailboxTypeAttr];if(!e)return null;const a=Array.isArray(e)?String(e[0]):String(e),i=/^\s*cn\s*=\s*([^,]+)/i.exec(a);if(!i)return null;return{group:"group",mailinglist:"mailingList",teammailbox:"teamMailbox"}[i[1].trim().toLowerCase()]??null}async createTeamMailbox(t,e,a){const i=this.teamMailboxUrl(a);await this.callWebAdminApi("teamMailbox:create",i,"PUT",t,null,{teamMailbox:a}),await this.teamMailboxMembers(t,a,"PUT",members(e))}async getMemberEmails(t){const e=t.filter(t=>!c(t,this.config.group_dummy_user)).map(t=>this.server.ldap.queryLimit(async()=>{const e=await this.ldapGetAttributes(t,[this.mailAttr]);return e?this.attributeToString(e[this.mailAttr]):null}));return(await Promise.all(e)).filter(t=>null!==t)}async getGroupMail(t){const e=await this.ldapGetAttributes(t,["mail"]);return e?this.attributeToString(e.mail):null}async getGroupMailboxType(t){try{const e=await this.server.ldap.system.search({paged:!1,scope:"base",attributes:[this.mailboxTypeAttr]},t);if(e.searchEntries&&e.searchEntries.length>0)return this.getMailboxType(e.searchEntries[0])}catch(e){const a=e instanceof Error?e.message:String(e);this.logger.debug(`Could not get mailbox type for group ${t}: ${a}`)}return null}async _handleDelegationChange(t,e){const a=await this.ldapGetAttributes(t,[this.mailAttr]);if(!a)return void this.logger.warn({plugin:this.name,event:"onLdapChange",dn:t,message:"Could not find user entry to get mail attribute"});const i=a[this.mailAttr];if(!i||"string"!=typeof i)return void this.logger.warn({plugin:this.name,event:"onLdapChange",dn:t,message:"User has no mail attribute, cannot manage delegation"});const s=this.config.delegation_attribute;if(!s)return;const[r,n]=e[s]||[],o=this._normalizeToArray(r),l=this._normalizeToArray(n),m=l.filter(t=>!o.includes(t)),d=o.filter(t=>!l.includes(t)),u=m.map(t=>this.server.ldap.queryLimit(async()=>{const e=await this._getDelegateEmail(t);return{dn:t,email:e}})),h=d.map(t=>this.server.ldap.queryLimit(async()=>{const e=await this._getDelegateEmail(t);return{dn:t,email:e}})),[c,g]=await Promise.all([Promise.all(u),Promise.all(h)]),p=[];for(const{dn:e,email:a}of c)a&&p.push(this.callWebAdminApi("onLdapChange:addDelegation",`${this.webadminUrl}/users/${i}/authorizedUsers/${a}`,"PUT",t,null,{userMail:i,delegateEmail:a,delegateDN:e,action:"add"},b));for(const{dn:e,email:a}of g)a&&p.push(this.callWebAdminApi("onLdapChange:removeDelegation",`${this.webadminUrl}/users/${i}/authorizedUsers/${a}`,"DELETE",t,null,{userMail:i,delegateEmail:a,delegateDN:e,action:"remove"}));await Promise.all(p)}async _getDelegateEmail(t){const e=await this.ldapGetAttributes(t,[this.mailAttr]);return e?this.attributeToString(e[this.mailAttr]):(this.logger.warn({plugin:this.name,event:"getDelegateEmail",dn:t,message:"Could not resolve delegate DN to email"}),null)}_normalizeToArray(t){return t?Array.isArray(t)?t:[t]:[]}}export{A as QuotaParams,James as default};
1
+ import{Type as t}from"@sinclair/typebox";import{TwakePlugin as e}from"../../abstract/twakePlugin.js";import"../../abstract/plugin.js";import{ConflictError as a}from"../../lib/errors.js";import{extractLdapCode as i}from"../../lib/ldapCodes.js";import{currentOperation as s}from"../../lib/operation.js";import{wantJson as r}from"../../lib/expressFormatedResponses.js";import{roleAttribute as n}from"../../config/schema.js";import{domainBranches as o,mailDomains as l}from"../../lib/mailDomains.js";import{isDnInBranch as m,parseDn as d,escapeLdapFilter as h,normalizeDn as u,isDummyMemberDn as c}from"../../lib/utils.js";import{jamesOptions as g,jamesOptionsOf as p}from"./james.options.js";import{defineRoute as f}from"../../lib/route.js";const b=[400,404],w=[500,502,503,504],pickDefaultIdentity=t=>(Array.isArray(t)?t:[t]).filter(t=>t&&t.id&&!1!==t.mayDelete).sort((t,e)=>(t.sortOrder??0)-(e.sortOrder??0))[0],members=t=>valuesOf(t??void 0,"member")??[],sameAttr=(t,e)=>t.split(";")[0].toLowerCase()===e.split(";")[0].toLowerCase(),valuesOf=(t,e)=>{if(!t)return;const a=Object.keys(t).filter(t=>sameAttr(t,e));return a.length?a.flatMap(e=>{return null==(a=t[e])?[]:(Array.isArray(a)?a:[a]).map(String);var a}):void 0},valuesAfter=(t,e,a,i)=>{let s=valuesOf(t,a)??[];const r=valuesOf(e.add,a);r&&(s=[...s,...r]);const n=valuesOf(e.replace,a);n&&(s=n);const o=e.delete;if(Array.isArray(o))o.some(t=>sameAttr(t,a))&&(s=[]);else{const t=valuesOf(o,a);if(t){const e=new Set(t.map(i));s=e.size?s.filter(t=>!e.has(i(t))):[]}}return s},memberKey=t=>{try{return u(t)}catch{return t.toLowerCase()}},y=["groupofnames","groupofuniquenames","groupofurls"],isGroup=t=>(Array.isArray(t)?t:t?[t]:[]).some(t=>y.includes(String(t).toLowerCase())),sameDn=(t,e)=>{try{return u(t)===u(e)}catch{return!1}},A=t.Object({user:t.String({minLength:1})});class James extends e{static optionDefinitions=g;name="james";roles=["consistency","api","configurable"];dependencies={onLdapChange:"core/ldap/onChange",ldapGroups:"core/ldap/groups"};health(){return this.webadminHealth()}quotaAttr;aliasAttr;mailboxTypeAttr;forwardAttr;initDelay;constructor(t){const e=p(t.config);super(t,{url:e.james_webadmin_url,token:e.james_webadmin_token,concurrency:e.james_concurrency,urlOption:"james_webadmin_url",tokenOption:"james_webadmin_token",concurrencyOption:"james_concurrency"}),this.quotaAttr=this.config.quota_attribute||"",this.aliasAttr=this.config.alias_attribute||"mailAlternateAddress",this.mailboxTypeAttr=e.james_mailbox_type_attribute,this.forwardAttr=this.config.forward_attribute||"mailForwardingAddress",this.initDelay=e.james_init_delay,this.retryAttempts=Math.max(1,e.james_retry_attempts),this.retryDelay=Math.max(0,e.james_retry_delay)}get settings(){return this.options(g)}api(t){const e=this.config.api_prefix||"/api";f(t,{method:"get",path:`${e}/v1/users/:user/quota-usage`,params:A,handler:async({req:t,res:e})=>this.getUserQuota(t,e)}),this.logger.info(`James API registered at ${e}/v1/users/:user/quota-usage`)}async getUserQuota(t,e){if(!r(t,e))return;const a=t.params.user;try{const i=await this.findReadableUser(a,t,[this.mailAttr]);if(!i)return void e.status(404).json({error:`User ${a} not found`});const s=i[this.mailAttr];if(!s)return void e.status(400).json({error:`User ${a} has no mail attribute`});const r=Array.isArray(s)?String(s[0]):String(s),n=this.webadminPath("quota","users",r),o=await this.requestLimit(()=>this.fetch(n,{method:"GET",headers:this.createHeaders()}));if(!o.ok)return void(404===o.status?e.status(404).json({error:`Quota not found for user ${a}`}):e.status(502).json({error:`Failed to retrieve quota from James: ${o.statusText}`}));const l=await o.json();e.json(l)}catch(t){this.logger.error({plugin:this.name,event:"getUserQuota",username:a,error:t instanceof Error?t.message:String(t)}),e.status(500).json({error:"Internal server error while retrieving quota"})}}hooks={ldapaddrequest:async([t,e,a])=>{const i=this.teamMailboxDomain(valuesOf(e,this.mailAttr),valuesOf(e,this.mailboxTypeAttr));return i&&await this.assertMembersAt(t,i,valuesOf(e,"member")??[]),void 0!==a?[t,e,a]:[t,e]},ldapmodifyrequest:async([t,e,a,i])=>(await this.guardTeamMailboxChange(t,e),await this.guardDomainChange(t,e),[t,e,a,i]),ldapdeleterequest:async([t,e])=>{for(const e of Array.isArray(t)?t:[t])e&&await this.guardDomainRemoval(e,"delete");return[t,e]},ldaprenamerequest:async([t,e,a])=>(await this.guardDomainMove(t,e),[t,e,a]),ldaprenamedone:async(t,e)=>{void 0===s()&&await this.followMove(t,e)},ldapadddone:(t,e)=>this.provisionAccount(t,e),onLdapMailChange:async(t,e,a)=>{const i=e?Array.isArray(e)?String(e[0]):String(e):null,s=a?Array.isArray(a)?String(a[0]):String(a):null;if(!i)return void this.logger.debug(`Skipping mail rename for ${t}: oldmail is empty (mail attribute was added, not changed)`);if(!s)return void this.logger.debug(`Skipping mail rename for ${t}: newmail is empty (mail attribute was deleted, not changed)`);if(await this.isGroupDn(t))return;const r=await this.defaultSignatures(i),n=await this.ldapGetAttributes(t,[this.forwardAttr,this.displayNameAttr,"cn","givenName","sn",this.mailAttr]);await this.dropStaleForwards(t,s,this.ownForwards(n?.[this.forwardAttr])),await this.callWebAdminApi("onLdapMailChange",`${this.webadminPath("users",i,"rename",s)}?action=rename&force`,"POST",t,null,{oldmail:i,newmail:s},b);try{const e=await this.server.ldap.system.search({paged:!1,scope:"base",attributes:[this.aliasAttr]},t);if(e.searchEntries&&e.searchEntries.length>0){const a=this.getAliases(e.searchEntries[0][this.aliasAttr]);a.length>0&&await Promise.all([...a.map(e=>this.callWebAdminApi("onLdapMailChange-delete",this.webadminPath("address","aliases",i,"sources",e),"DELETE",t,null,{oldmail:i,alias:e})),...a.map(e=>this.callWebAdminApi("onLdapMailChange-create",this.webadminPath("address","aliases",s,"sources",e),"PUT",t,null,{newmail:s,alias:e},b))])}}catch(t){this.logger.debug("Could not fetch aliases for mail change:",t)}const o=n&&this.getDisplayNameFromAttributes(n);o&&await this.updateJamesIdentity(t,s,o,r)},onLdapAliasChange:async(t,e,a,i)=>{if(await this.isGroupDn(t))return;const s=a.map(t=>this.normalizeAlias(t)),r=i.map(t=>this.normalizeAlias(t)),n=s.filter(t=>!r.includes(t)),o=r.filter(t=>!s.includes(t));await Promise.all([...n.map(a=>this.callWebAdminApi("onLdapAliasChange-delete",this.webadminPath("address","aliases",e,"sources",a),"DELETE",t,null,{mail:e,alias:a,action:"delete"})),...o.map(async a=>{await this.dropStaleForwards(t,a,[]),await this.callWebAdminApi("onLdapAliasChange-add",this.webadminPath("address","aliases",e,"sources",a),"PUT",t,null,{mail:e,alias:a,action:"add"},b)})])},onLdapQuotaChange:async(t,e,a,i)=>{if(await this.isGroupDn(t))return;const s=i>0;await this.callWebAdminApi("onLdapQuotaChange",this.webadminPath("quota","users",e,"size"),s?"PUT":"DELETE",t,s?i.toString():null,{oldQuota:a,newQuota:i},b)},onLdapForwardChange:async(t,e,a,i)=>{if(!this.settings.james_sync_forwards)return;if(await this.isGroupDn(t))return;const s=this.extractMailDomain(e);if(!s)return void this.logger.error(`Cannot extract domain from mail ${e} for forward management`);const r=a.filter(t=>!i.includes(t)),n=i.filter(t=>!a.includes(t));await Promise.all([...r.map(a=>this.callWebAdminApi("onLdapForwardChange-delete",this.webadminPath("address","forwards",e,"targets",a),"DELETE",t,null,{mail:e,forward:a,domain:s,action:"delete"})),...n.map(a=>this.callWebAdminApi("onLdapForwardChange-add",this.webadminPath("address","forwards",e,"targets",a),"PUT",t,null,{mail:e,forward:a,domain:s,action:"add"},b))])},onLdapChange:async(t,e)=>{this.config.delegation_attribute&&e[this.config.delegation_attribute]&&!await this.isGroupDn(t)&&await this._handleDelegationChange(t,e)},onLdapDisplayNameChange:async t=>{const e=[this.mailAttr,this.displayNameAttr,"cn","givenName","sn","objectClass"],a=await this.ldapGetAttributes(t,e);if(!a)return void this.logger.warn(`Cannot update James identity: entry not found for ${t}`);if(isGroup(a.objectClass))return;const i=this.attributeToString(a[this.mailAttr]);if(!i)return void this.logger.warn(`Cannot update James identity: no mail found for ${t}`);const s=this.getDisplayNameFromAttributes(a);if(s)return this.updateJamesIdentity(t,i,s);this.logger.warn(`Cannot update James identity: no display name found for ${t}`)},onLdapEntryChange:async(t,e,a)=>{e&&a&&!e[this.mailAttr]&&a[this.mailAttr]&&await this.provisionAccount(t,a),await this.followTeamMailbox(e,a),await this.followDomains(e,a)}};domainAttribute(t){let e,a=-1;for(const{base:i,attribute:s}of o(this.server))try{if(sameDn(t,i)||!m(t,i))continue;const r=d(i).length;r>a&&([e,a]=[s,r])}catch{}return e}domainsOf(t){const e=t?.dn&&this.domainAttribute(String(t.dn));return e?l(valuesOf(t,e)):[]}afterLoad(){if(!this.server.loadedPlugins.trash)return;const t=String(this.config.trash_watched_bases??"").split(";").map(t=>t.trim()).filter(Boolean);for(const{base:e}of o(this.server)){const a=t.length?t.find(t=>{try{return m(e,t)||m(t,e)}catch{return!1}}):"every branch";a&&this.logger.warn(`James: the trash watches the mail domains of ${e} (--trash-watched-bases: ${a}): a domain deleted there may skip the check that it is still in use; leave the domain branch out of the trash, see docs/usage/plugins/integrations/james-mail.md#trash`)}}async followDomains(t,e,a=!1){const i=this.domainsOf(t),s=this.domainsOf(e);if(!i.length&&!s.length)return;const r=String((e??t).dn),call=(t,e)=>this.callWebAdminApi("PUT"===e?"domain:create":"domain:delete",this.webadminPath("domains",t),e,r,null,{domain:t},w);await Promise.all([...s.filter(t=>!i.includes(t)).map(t=>this.inDomainOrder(t,()=>call(t,"PUT"))),...i.filter(t=>!s.includes(t)).map(t=>this.inDomainOrder(t,async()=>{if(await this.domainDeclaredElsewhere(t,r))return;const e=a?await this.addressAt(t):null;e?this.logger.error({plugin:this.name,event:"domain:delete",dn:r,domain:t,result:"skipped",error:`${e} still has an address at ${t}: the domain is kept in James`}):await call(t,"DELETE")}))])}async followMove(t,e){const a=[this.domainAttribute(t),this.domainAttribute(e)].filter(t=>Boolean(t)).filter((t,e,a)=>a.findIndex(e=>sameAttr(e,t))===e);if(!a.length)return;const i=await this.ldapGetAttributes(e,a);i&&await this.followDomains({...i,dn:t},{...i,dn:e},!0)}async inDomainOrder(t,e){const run=async()=>{await e()},a=(this.domainQueue.get(t)??Promise.resolve()).then(run,run);this.domainQueue.set(t,a);try{await a}finally{this.domainQueue.get(t)===a&&this.domainQueue.delete(t)}}domainQueue=new Map;async firstEntry(t,e,a,s){try{const i=await this.server.ldap.system.search({paged:{pageSize:500},scope:"sub",filter:e,attributes:a},t);for await(const t of i)for(const e of t.searchEntries||[])if(s(e))return e;return null}catch(t){if(32===i(t))return null;throw t}}async domainDeclaredElsewhere(t,e){try{for(const{base:a,attribute:i}of o(this.server)){if(await this.firstEntry(a,`(${i}=${h(t)})`,["1.1"],t=>{const a=String(t.dn),s=this.domainAttribute(a);return!sameDn(a,e)&&!!s&&sameAttr(s,i)}))return!0}return!1}catch(a){return this.logger.error({plugin:this.name,event:"domain:delete",dn:e,domain:t,result:"skipped",error:a instanceof Error?a.message:String(a)}),!0}}async guardDomainRemoval(t,e){const i=this.domainAttribute(t);if(!i)return;const s=await this.ldapGetAttributes(t,[i]),r=l(valuesOf(s??void 0,i));if(!r.length)return;const n=await this.domainLinkHolder(t,String(s.dn??t));if(n)throw this.logger.warn(`${e} of ${t} refused: organization ${n} links it`),new a(`Cannot ${e} ${t}: an organization still links mail domain ${r.join(", ")}`);for(const a of r)await this.assertNoAddressAt(t,a,`Cannot ${e} ${t}`)}async guardDomainMove(t,e){const a=this.domainAttribute(t);if(!a)return;const i=this.domainAttribute(e);if(!i)return this.guardDomainRemoval(t,"move");const s=await this.ldapGetAttributes(t,sameAttr(a,i)?[a]:[a,i])??void 0,r=l(valuesOf(s,i));for(const e of l(valuesOf(s,a)))r.includes(e)||await this.assertNoAddressAt(t,e,`Cannot move ${t}`)}async guardDomainChange(t,e){const a=this.domainAttribute(t);if(!a||!((t,e)=>[t.add,t.replace].some(t=>t&&Object.keys(t).some(t=>sameAttr(t,e)))||(Array.isArray(t.delete)?t.delete.some(t=>sameAttr(t,e)):Object.keys(t.delete??{}).some(t=>sameAttr(t,e))))(e,a))return;const i=await this.ldapGetAttributes(t,[a]);if(!i)return;const s=l(valuesAfter(i,e,a,t=>t.toLowerCase()));for(const e of l(valuesOf(i,a)))s.includes(e)||await this.assertNoAddressAt(t,e,`Cannot remove mail domain ${e} from ${t}`)}domainLinkAttributes(){const t=this.server.loadedPlugins.ldapOrganizations,e=this.server.loadedPlugins.ldapFlatGeneric,a=[t?.schema,...(e?.instances||[]).map(t=>t.schema)].map(t=>n(t,"domainLink"));a.push(this.config.enterprise_domain_link_attribute);const i=[];for(const t of a)t&&!i.some(e=>sameAttr(e,t))&&i.push(t);return i}async domainLinkHolder(t,e){const a=this.domainLinkAttributes();if(!a.length)return null;const i=[...new Set([t,e,u(t)])],s=`(|${a.flatMap(t=>i.map(e=>`(${t}=${h(e)})`)).join("")})`,r=await this.firstEntry(this.config.ldap_base||"",s,a,e=>a.some(a=>(valuesOf(e,a)??[]).some(e=>sameDn(e,t))));return r?String(r.dn):null}async addressAt(t){const e=[this.mailAttr,this.aliasAttr],atDomain=e=>this.extractMailDomain(this.normalizeAlias(e.trim()))?.toLowerCase()===t,a=await this.firstEntry(this.config.ldap_base||"",`(|${e.map(t=>`(${t}=*)`).join("")})`,e,t=>e.some(e=>(valuesOf(t,e)??[]).some(atDomain)));return a?String(a.dn):null}async assertNoAddressAt(t,e,i){if(await this.domainDeclaredElsewhere(e,t))return;const s=await this.addressAt(e);if(s)throw this.logger.warn(`${i}: ${s} still has an address at ${e}`),new a(`${i}: addresses at ${e} still exist, and James would refuse their mail`)}teamMailboxOf(t){if(!t||"teamMailbox"!==this.getMailboxType(t))return null;const e=this.attributeToString(t[this.mailAttr]);if(!e)return null;const a=String(t.dn),i=this.settings.james_mailing_list_branch;if(i.some(t=>a===t||a.endsWith(","+t)))return{mail:e,refused:`Team mailbox cannot be in mailing list branches: ${i.join(", ")}`};const s=e.lastIndexOf("@");return/^[#a-zA-Z0-9_-]{1,255}$/.test(e.slice(0,s))&&this.extractMailDomain(e)?{mail:e,refused:null}:{mail:e,refused:"A team mailbox address is a name of letters, digits, #, - and _ at a domain"}}async provisionAccount(t,e){const a=e[this.mailAttr],i=e[this.quotaAttr],s=e[this.aliasAttr];if(!a||isGroup(e.objectClass))return;const r=Array.isArray(a)?String(a[0]):String(a);if(this.initDelay>0&&await new Promise(t=>setTimeout(t,this.initDelay)),i){const e=Array.isArray(i)?Number(i[0]):Number(i);!isNaN(e)&&e>0&&await this.callWebAdminApi("ldapadddone:quota",this.webadminPath("quota","users",r,"size"),"PUT",t,e.toString(),{mail:r,quota:e},b)}const n=this.getAliases(s);await Promise.all([this.dropStaleForwards(t,r,this.ownForwards(e[this.forwardAttr])),...n.map(e=>this.dropStaleForwards(t,e,[]))]),n.length>0&&await Promise.all(n.map(e=>this.callWebAdminApi("ldapadddone:alias",this.webadminPath("address","aliases",r,"sources",e),"PUT",t,null,{mail:r,alias:e},b)));const o=this.getDisplayNameFromAttributes(e);o&&await this.updateJamesIdentity(t,r,o)}async followTeamMailbox(t,e){const a=this.teamMailboxOf(t),i=this.teamMailboxOf(e);if(!a&&!i)return;const s=String((e??t).dn);i?.refused&&i.refused!==a?.refused&&this.logger.error({plugin:this.name,event:"teamMailbox",dn:s,mail:i.mail,error:i.refused});const r=a&&!a.refused?a.mail:null,n=i&&!i.refused?i.mail:null;if(!r&&!n)return;const o=memberKey(s),follow=async()=>{if(r&&n&&r.toLowerCase()===n.toLowerCase()){const a=new Map(members(t).map(t=>[memberKey(t),t])),i=[];for(const t of members(e))a.delete(memberKey(t))||i.push(t);return void await Promise.all([this.teamMailboxMembers(s,n,"PUT",i),this.teamMailboxMembers(s,n,"DELETE",[...a.values()])])}r&&await this.teamMailboxMembers(s,r,"DELETE",members(t)),n&&await this.createTeamMailbox(s,e,n)},l=(this.teamMailboxQueue.get(o)??Promise.resolve()).then(follow,follow);this.teamMailboxQueue.set(o,l);try{await l}finally{this.teamMailboxQueue.get(o)===l&&this.teamMailboxQueue.delete(o)}}teamMailboxQueue=new Map;getConfigApiData(){return{teamMailboxAnyDomain:this.settings.james_team_mailbox_any_domain}}teamMailboxDomain(t,e){if(this.settings.james_team_mailbox_any_domain||"teamMailbox"!==this.getMailboxType({[this.mailboxTypeAttr]:e}))return null;const a=this.attributeToString(t);return a&&this.extractMailDomain(a)?.toLowerCase()||null}async guardTeamMailboxChange(t,e){if(this.settings.james_team_mailbox_any_domain)return;const a=[this.mailAttr,this.mailboxTypeAttr,"member"];if(![e.add,e.replace].some(t=>t&&Object.keys(t).some(t=>a.some(e=>sameAttr(t,e)))))return;const i=await this.ldapGetAttributes(t,a);if(!i)return;const after=(t,a=t=>t.toLowerCase())=>valuesAfter(i,e,t,a),s=this.teamMailboxDomain(after(this.mailAttr),after(this.mailboxTypeAttr));if(!s)return;const r=new Set((valuesOf(i,"member")??[]).map(memberKey)),n=after("member",memberKey),o=n.filter(t=>!r.has(memberKey(t))),l=s!==this.teamMailboxDomain(valuesOf(i,this.mailAttr),valuesOf(i,this.mailboxTypeAttr));await this.assertMembersAt(t,s,o,l?n.filter(t=>r.has(memberKey(t))):[])}async assertMembersAt(t,e,i,s=[]){const foreign=async t=>(await this.getMemberEmails(t)).filter(t=>this.extractMailDomain(t)?.toLowerCase()!==e),[r,n]=await Promise.all([foreign(i),foreign(s)]);if(0===r.length&&0===n.length)return;const o=r.length?`${r.slice(0,5).join(", ")}${r.length>5?", …":""} `+(r.length>1?"are":"is")+" not":"",l=n.length?"some of the members it holds are not":"";throw new a(`The members of team mailbox ${t} must have an address at ${e}: `+[o,l].filter(Boolean).join(", and "))}teamMailboxUrl(t,...e){const a=t.toLowerCase(),i=a.lastIndexOf("@");return this.webadminPath("domains",a.slice(i+1),"team-mailboxes",a.slice(0,i),...e)}async teamMailboxMembers(t,e,a,i){if(!i.length)return;const s=await this.getMemberEmails(i);await Promise.all(s.map(i=>this.callWebAdminApi("teamMailbox:"+("PUT"===a?"addMember":"removeMember"),this.teamMailboxUrl(e,"members",i),a,t,null,{teamMailbox:e,memberMail:i})))}getDisplayNameFromAttributes(t){const e=this.attributeToString(t[this.displayNameAttr]);if(e)return e;const a=this.attributeToString(t.cn);if(a)return a;const i=this.attributeToString(t.givenName),s=this.attributeToString(t.sn);if(i||s){const t=[];return i&&t.push(i),s&&t.push(s),t.join(" ")}const r=this.attributeToString(t[this.mailAttr]);return r||null}async isGroupDn(t){const e=await this.ldapGetAttributes(t,["objectClass"]);return isGroup(e?.objectClass)}async getMailFromDN(t){const e=await this.ldapGetAttributes(t,[this.mailAttr]);return e?this.attributeToString(e[this.mailAttr]):null}async getDisplayNameFromDN(t){const e=[this.displayNameAttr,"cn","givenName","sn",this.mailAttr],a=await this.ldapGetAttributes(t,e);return a?this.getDisplayNameFromAttributes(a):null}async generateSignature(t){const e=this.settings.james_signature_template;if(!e)return null;const a=await this.ldapGetAttributes(t);if(!a)return null;let i=e;return i=i.replace(/\{(\w+)\}/g,(t,e)=>"string"==typeof e&&a&&"object"==typeof a&&Object.hasOwn(a,e)&&this.attributeToString(a[e])||""),i}async updateJamesIdentity(t,e,a,i={}){const s=(this.identityQueue.get(e)??Promise.resolve()).then(()=>this.doUpdateJamesIdentity(t,e,a,i));this.identityQueue.set(e,s);try{await s}finally{this.identityQueue.get(e)===s&&this.identityQueue.delete(e)}}identityQueue=new Map;async doUpdateJamesIdentity(t,e,a,i){const s={plugin:this.name,event:"onLdapDisplayNameChange",result:"error",dn:t,mail:e,displayName:a};for(let r=1;;r++){try{const n=this.webadminPath("users",e,"identities"),o=await this.requestLimit(()=>this.fetch(`${n}?default=true`,{method:"GET",headers:this.createHeaders()}));let l;if(o.ok)l=pickDefaultIdentity(await o.json());else if(404!==o.status){const t="get_default_identity";if(this.shouldRetry(o.status,b,r)){await this.warnAndWait(s,t,r,o);continue}return void this.logger.error({...s,step:t,...r>1?{attempts:r}:{},http_status:o.status,http_status_text:o.statusText})}const m=await this.generateSignature(t),d=l?.id?{id:l.id,email:l.email,name:a}:{email:e,name:a,sortOrder:0};m?d.htmlSignature=m:Object.assign(d,i);const h=l?.id?"update_identity":"create_identity",u=await this.requestLimit(()=>this.fetch(l?.id?this.webadminPath("users",e,"identities",l.id):n,{method:l?.id?"PUT":"POST",headers:this.createHeaders("application/json"),body:JSON.stringify(d)}));if(u.ok)this.logger.info({...s,step:h,result:"success",http_status:u.status});else{if(this.shouldRetry(u.status,b,r)){await this.warnAndWait(s,h,r,u);continue}this.logger.error({...s,step:h,...r>1?{attempts:r}:{},http_status:u.status,http_status_text:u.statusText})}}catch(t){this.logger.error({...s,error:`${t}`})}return}}async defaultSignatures(t){try{const e=await this.requestLimit(()=>this.fetch(`${this.webadminPath("users",t,"identities")}?default=true`,{method:"GET",headers:this.createHeaders()}));if(!e.ok)return{};const a=pickDefaultIdentity(await e.json()),i={};return a?.htmlSignature&&(i.htmlSignature=a.htmlSignature),a?.textSignature&&(i.textSignature=a.textSignature),i}catch(e){return this.logger.debug(`Could not read the identities of ${t}: ${e}`),{}}}ownForwards(t){return this.settings.james_sync_forwards?this.getAliases(t):[]}async dropStaleForwards(t,e,a){let i;try{const s=await this.requestLimit(()=>this.fetch(this.webadminPath("address","forwards",e),{method:"GET",headers:this.createHeaders()}));if(!s.ok)return void(404!==s.status&&this.logger.warn({plugin:this.name,event:"dropStaleForward",result:"error",dn:t,address:e,step:"list_forwards",http_status:s.status,http_status_text:s.statusText}));const r=a.map(t=>t.toLowerCase());i=(await s.json()).map(t=>t.mailAddress).filter(t=>t&&!r.includes(t.toLowerCase()))}catch(t){return void this.logger.debug(`Could not read the forwards of ${e}: ${t}`)}await Promise.all(i.map(a=>this.callWebAdminApi("dropStaleForward",this.webadminPath("address","forwards",e,"targets",a),"DELETE",t,null,{address:e,forward:a})))}async warnAndWait(t,e,a,i){this.logger.warn({...t,step:e,result:"retry",attempt:a,http_status:i.status,http_status_text:i.statusText}),await this.waitBeforeRetry(a)}async deleteUserData(t,e){const a={plugin:this.name,event:"deleteUserData",mail:t,fromStep:e};try{const i=new URL(this.webadminPath("users",t));i.searchParams.set("action","deleteData"),e&&i.searchParams.set("fromStep",e);const s=await this.requestLimit(()=>this.fetch(i.toString(),{method:"POST",headers:this.createHeaders()}));if(!s.ok)return this.logger.error({...a,http_status:s.status,http_status_text:s.statusText}),null;const r=await s.json();return this.logger.info({...a,http_status:s.status,taskId:r.taskId}),r}catch(t){return this.logger.error({...a,error:`${t}`}),null}}getMailboxType(t){const e=t[this.mailboxTypeAttr];if(!e)return null;const a=Array.isArray(e)?String(e[0]):String(e),i=/^\s*cn\s*=\s*([^,]+)/i.exec(a);if(!i)return null;return{group:"group",mailinglist:"mailingList",teammailbox:"teamMailbox"}[i[1].trim().toLowerCase()]??null}async createTeamMailbox(t,e,a){const i=this.teamMailboxUrl(a);await this.callWebAdminApi("teamMailbox:create",i,"PUT",t,null,{teamMailbox:a}),await this.teamMailboxMembers(t,a,"PUT",members(e))}async getMemberEmails(t){const e=t.filter(t=>!c(t,this.config.group_dummy_user)).map(t=>this.server.ldap.queryLimit(async()=>{const e=await this.ldapGetAttributes(t,[this.mailAttr]);return e?this.attributeToString(e[this.mailAttr]):null}));return(await Promise.all(e)).filter(t=>null!==t)}async getGroupMail(t){const e=await this.ldapGetAttributes(t,["mail"]);return e?this.attributeToString(e.mail):null}async getGroupMailboxType(t){try{const e=await this.server.ldap.system.search({paged:!1,scope:"base",attributes:[this.mailboxTypeAttr]},t);if(e.searchEntries&&e.searchEntries.length>0)return this.getMailboxType(e.searchEntries[0])}catch(e){const a=e instanceof Error?e.message:String(e);this.logger.debug(`Could not get mailbox type for group ${t}: ${a}`)}return null}async _handleDelegationChange(t,e){const a=await this.ldapGetAttributes(t,[this.mailAttr]);if(!a)return void this.logger.warn({plugin:this.name,event:"onLdapChange",dn:t,message:"Could not find user entry to get mail attribute"});const i=a[this.mailAttr];if(!i||"string"!=typeof i)return void this.logger.warn({plugin:this.name,event:"onLdapChange",dn:t,message:"User has no mail attribute, cannot manage delegation"});const s=this.config.delegation_attribute;if(!s)return;const[r,n]=e[s]||[],o=this._normalizeToArray(r),l=this._normalizeToArray(n),m=l.filter(t=>!o.includes(t)),d=o.filter(t=>!l.includes(t)),h=m.map(t=>this.server.ldap.queryLimit(async()=>{const e=await this._getDelegateEmail(t);return{dn:t,email:e}})),u=d.map(t=>this.server.ldap.queryLimit(async()=>{const e=await this._getDelegateEmail(t);return{dn:t,email:e}})),[c,g]=await Promise.all([Promise.all(h),Promise.all(u)]),p=[];for(const{dn:e,email:a}of c)a&&p.push(this.callWebAdminApi("onLdapChange:addDelegation",this.webadminPath("users",i,"authorizedUsers",a),"PUT",t,null,{userMail:i,delegateEmail:a,delegateDN:e,action:"add"},b));for(const{dn:e,email:a}of g)a&&p.push(this.callWebAdminApi("onLdapChange:removeDelegation",this.webadminPath("users",i,"authorizedUsers",a),"DELETE",t,null,{userMail:i,delegateEmail:a,delegateDN:e,action:"remove"}));await Promise.all(p)}async _getDelegateEmail(t){const e=await this.ldapGetAttributes(t,[this.mailAttr]);return e?this.attributeToString(e[this.mailAttr]):(this.logger.warn({plugin:this.name,event:"getDelegateEmail",dn:t,message:"Could not resolve delegate DN to email"}),null)}_normalizeToArray(t){return t?Array.isArray(t)?t:[t]:[]}}export{A as QuotaParams,James as default};
@@ -1 +1 @@
1
- import{Type as t}from"@sinclair/typebox";import{TwakePlugin as r}from"../../abstract/twakePlugin.js";import"../../abstract/plugin.js";import{wantJson as e}from"../../lib/expressFormatedResponses.js";import{isDnInBranch as i,normalizeDn as s}from"../../lib/utils.js";import{migrationProxyOptions as a,migrationProxyOptionsOf as o}from"./migrationProxy.options.js";import{defineRoute as n}from"../../lib/route.js";const l=[500,502,503,504],h=t.Object({user:t.String({minLength:1})});class MigrationProxy extends r{static optionDefinitions=a;name="migrationProxy";roles=["consistency","api"];dependencies={onLdapChange:"core/ldap/onChange"};branches;aliasAttr;queues=new Map;constructor(t){const r=o(t.config);if(super(t,{url:r.migration_proxy_url,token:r.migration_proxy_token,concurrency:r.migration_proxy_concurrency,urlOption:"migration_proxy_url",tokenOption:"migration_proxy_token",concurrencyOption:"migration_proxy_concurrency"}),this.branches=r.migration_proxy_branch,!this.branches.length)throw new Error(`${this.name}: --migration-proxy-branch is required`);this.aliasAttr=this.config.alias_attribute||"mailAlternateAddress",this.retryAttempts=Math.max(1,r.migration_proxy_retry_attempts),this.retryDelay=Math.max(0,r.migration_proxy_retry_delay)}health(){return this.webadminHealth()}api(t){const r=this.config.api_prefix||"/api";n(t,{method:"get",path:`${r}/v1/users/:user/mail-migration`,params:h,handler:async({req:t,res:r})=>this.getMigrationStatus(t,r)})}hooks={onLdapEntryChange:(t,r,e)=>{if(this.branches.some(r=>i(t,r)))return this.inOrder(t,()=>this.follow(t,r,e))}};inOrder(t,r){let e;try{e=s(t)}catch{e=t.toLowerCase()}const i=(this.queues.get(e)??Promise.resolve()).then(r,r);return this.queues.set(e,i),i.finally(()=>{this.queues.get(e)===i&&this.queues.delete(e)})}addresses(t){if(!t)return{primary:null,all:[]};const r=this.getAliases(t[this.mailAttr]),e=new Set,i=[...r,...this.getAliases(t[this.aliasAttr])].filter(t=>{const r=t.toLowerCase();return!e.has(r)&&(e.add(r),!0)});return{primary:r[0]??null,all:i}}async follow(t,r,e){const i=this.addresses(r),s=this.addresses(e),lower=t=>new Set(t.map(t=>t.toLowerCase())),a=lower(i.all),o=lower(s.all),n=s.all.filter(t=>!a.has(t.toLowerCase())),l=i.all.filter(t=>!o.has(t.toLowerCase()));if(!n.length&&!l.length)return;let h;if(i.primary)if(s.primary){h="onLdapEntryChange:update";const r={plugin:this.name,event:h,dn:t,mail:i.primary,added:n,removed:l};let e;try{e=await this.isMigrated(i.primary,!0)}catch(t){return void this.logger.error({...r,result:"error",error:t instanceof Error?t.message:String(t)})}if(!e)return void this.logger.debug({...r,result:"ignored"})}else h="onLdapEntryChange:delete";else h="onLdapEntryChange:add";(await Promise.all(n.map(r=>this.call(h,t,"PUT",r)))).every(Boolean)&&await Promise.all(l.map(r=>this.call(h,t,"DELETE",r)))}userUrl(t){return`${this.webadminUrl}/migratedUsers/${encodeURIComponent(t)}`}call(t,r,e,i){return this.callWebAdminApi(t,this.userUrl(i),e,r,null,{mail:i},l)}async isMigrated(t,r=!1){for(let e=1;;e++){const i=await this.requestLimit(()=>this.fetch(this.userUrl(t),{method:"HEAD",headers:this.createHeaders(),signal:AbortSignal.timeout(1e4)}));if(404===i.status)return!1;if(i.ok)return!0;if(!r||!this.shouldRetry(i.status,l,e))throw new Error(`Migration proxy answered ${i.status} ${i.statusText}`);await this.waitBeforeRetry(e)}}async getMigrationStatus(t,r){if(!e(t,r))return;const i=t.params.user,s=await this.findReadableUser(i,t,[this.mailAttr]);if(!s)return void r.status(404).json({error:`User ${i} not found`});const a=this.attributeToString(s[this.mailAttr]);if(a)try{r.json({migrated:await this.isMigrated(a)})}catch(t){this.logger.error({plugin:this.name,event:"getMigrationStatus",username:i,error:t instanceof Error?t.message:String(t)}),r.status(502).json({error:"Failed to ask the migration proxy"})}else r.status(400).json({error:`User ${i} has no mail attribute`})}}export{h as MailMigrationParams,MigrationProxy as default};
1
+ import{Type as t}from"@sinclair/typebox";import{TwakePlugin as r,assertPathSegment as e}from"../../abstract/twakePlugin.js";import"../../abstract/plugin.js";import{wantJson as i}from"../../lib/expressFormatedResponses.js";import{isDnInBranch as s,normalizeDn as a}from"../../lib/utils.js";import{migrationProxyOptions as o,migrationProxyOptionsOf as n}from"./migrationProxy.options.js";import{defineRoute as l}from"../../lib/route.js";const h=[500,502,503,504],u=t.Object({user:t.String({minLength:1})});class MigrationProxy extends r{static optionDefinitions=o;name="migrationProxy";roles=["consistency","api"];dependencies={onLdapChange:"core/ldap/onChange"};branches;aliasAttr;queues=new Map;constructor(t){const r=n(t.config);if(super(t,{url:r.migration_proxy_url,token:r.migration_proxy_token,concurrency:r.migration_proxy_concurrency,urlOption:"migration_proxy_url",tokenOption:"migration_proxy_token",concurrencyOption:"migration_proxy_concurrency"}),this.branches=r.migration_proxy_branch,!this.branches.length)throw new Error(`${this.name}: --migration-proxy-branch is required`);this.aliasAttr=this.config.alias_attribute||"mailAlternateAddress",this.retryAttempts=Math.max(1,r.migration_proxy_retry_attempts),this.retryDelay=Math.max(0,r.migration_proxy_retry_delay)}health(){return this.webadminHealth()}api(t){const r=this.config.api_prefix||"/api";l(t,{method:"get",path:`${r}/v1/users/:user/mail-migration`,params:u,handler:async({req:t,res:r})=>this.getMigrationStatus(t,r)})}hooks={onLdapEntryChange:(t,r,e)=>{if(this.branches.some(r=>s(t,r)))return this.inOrder(t,()=>this.follow(t,r,e))}};inOrder(t,r){let e;try{e=a(t)}catch{e=t.toLowerCase()}const i=(this.queues.get(e)??Promise.resolve()).then(r,r);return this.queues.set(e,i),i.finally(()=>{this.queues.get(e)===i&&this.queues.delete(e)})}addresses(t){if(!t)return{primary:null,all:[]};const r=this.getAliases(t[this.mailAttr]),e=new Set,i=[...r,...this.getAliases(t[this.aliasAttr])].filter(t=>{const r=t.toLowerCase();return!e.has(r)&&(e.add(r),!0)});return{primary:r[0]??null,all:i}}async follow(t,r,e){const i=this.addresses(r),s=this.addresses(e),lower=t=>new Set(t.map(t=>t.toLowerCase())),a=lower(i.all),o=lower(s.all),n=s.all.filter(t=>!a.has(t.toLowerCase())),l=i.all.filter(t=>!o.has(t.toLowerCase()));if(!n.length&&!l.length)return;let h;if(i.primary)if(s.primary){h="onLdapEntryChange:update";const r={plugin:this.name,event:h,dn:t,mail:i.primary,added:n,removed:l};let e;try{e=await this.isMigrated(i.primary,!0)}catch(t){return void this.logger.error({...r,result:"error",error:t instanceof Error?t.message:String(t)})}if(!e)return void this.logger.debug({...r,result:"ignored"})}else h="onLdapEntryChange:delete";else h="onLdapEntryChange:add";(await Promise.all(n.map(r=>this.call(h,t,"PUT",r)))).every(Boolean)&&await Promise.all(l.map(r=>this.call(h,t,"DELETE",r)))}userUrl(t){return e(t),`${this.webadminUrl}/migratedUsers/${encodeURIComponent(t)}`}call(t,r,e,i){return this.callWebAdminApi(t,this.userUrl(i),e,r,null,{mail:i},h)}async isMigrated(t,r=!1){for(let e=1;;e++){const i=await this.requestLimit(()=>this.fetch(this.userUrl(t),{method:"HEAD",headers:this.createHeaders(),signal:AbortSignal.timeout(1e4)}));if(404===i.status)return!1;if(i.ok)return!0;if(!r||!this.shouldRetry(i.status,h,e))throw new Error(`Migration proxy answered ${i.status} ${i.statusText}`);await this.waitBeforeRetry(e)}}async getMigrationStatus(t,r){if(!i(t,r))return;const e=t.params.user,s=await this.findReadableUser(e,t,[this.mailAttr]);if(!s)return void r.status(404).json({error:`User ${e} not found`});const a=this.attributeToString(s[this.mailAttr]);if(a)try{r.json({migrated:await this.isMigrated(a)})}catch(t){this.logger.error({plugin:this.name,event:"getMigrationStatus",username:e,error:t instanceof Error?t.message:String(t)}),r.status(502).json({error:"Failed to ask the migration proxy"})}else r.status(400).json({error:`User ${e} has no mail attribute`})}}export{u as MailMigrationParams,MigrationProxy as default};
@@ -9,6 +9,18 @@ import type { DM } from '../bin/index.js';
9
9
  import { type HealthStatus } from '../lib/health.js';
10
10
  import type { AttributesList } from '../lib/ldapActions.js';
11
11
  import DmPlugin, { type Role } from './plugin.js';
12
+ /**
13
+ * Refuse a segment no encoding can keep literal.
14
+ *
15
+ * The WHATWG URL parser resolves `.`, `..` and every percent-encoded
16
+ * spelling of them (`%2E%2E`, `.%2e`) away, so an empty value, `.` or `..`
17
+ * cannot name one segment: left through, `..` would turn
18
+ * `/address/forwards/<mail>/targets/..` into a call on `<mail>` itself.
19
+ *
20
+ * @param value the segment, unencoded
21
+ * @throws Error when the value cannot name a path segment
22
+ */
23
+ export declare function assertPathSegment(value: string): void;
12
24
  /** What a Twake plugin needs to reach its WebAdmin API */
13
25
  export interface TwakeConnection {
14
26
  url: string;
@@ -41,6 +53,19 @@ export declare abstract class TwakePlugin extends DmPlugin {
41
53
  * @param connection WebAdmin URL, token and concurrency
42
54
  */
43
55
  constructor(server: DM, connection: TwakeConnection);
56
+ /**
57
+ * A WebAdmin URL under this plugin's base, each segment encoded.
58
+ *
59
+ * Addresses and RDN values come from the directory and may hold `/`, `?`,
60
+ * `#` or `%`: spliced raw, `fetch` resolves them as path, query or
61
+ * fragment delimiters, and a call made with the WebAdmin token acts on
62
+ * another user or resource. Each value is kept one segment, and decoding
63
+ * it gives the value back.
64
+ *
65
+ * @param segments path segments, unencoded
66
+ * @returns the absolute URL
67
+ */
68
+ protected webadminPath(...segments: string[]): string;
44
69
  /**
45
70
  * Create HTTP headers with optional Authorization token
46
71
  * @param contentType Optional Content-Type header value
@@ -108,10 +108,13 @@ export interface Config {
108
108
  auth_totp_step?: number;
109
109
  auth_hmac?: string[];
110
110
  auth_hmac_window?: number;
111
+ authz_linid1_cache_ttl?: number;
111
112
  oidc_server?: string;
112
113
  oidc_username_claim?: string;
113
114
  oidc_client_id?: string;
114
115
  oidc_client_secret?: string;
116
+ /** One or more keys for the session cookie, newest first; rotating keeps old sessions valid */
117
+ oidc_session_secret?: string[];
115
118
  base_url?: string;
116
119
  authz_per_route?: string[];
117
120
  /**
@@ -29,12 +29,14 @@ export type DmRequest = Request & {
29
29
  };
30
30
  /**
31
31
  * Tell whether a mount prefix covers a request path, with the rule Express
32
- * itself applies: on segment boundaries, so `/api/m` covers `/api/m` and
33
- * `/api/m/entry` but never `/api/machines`.
32
+ * applies to the routes of this server: on segment boundaries, so `/api/m`
33
+ * covers `/api/m` and `/api/m/entry` but never `/api/machines`, and
34
+ * ignoring letter case, since `case sensitive routing` is left off.
34
35
  *
35
- * The catch-all uses this to decide what another plugin already guards, so
36
- * it must agree with Express exactly — a looser rule here would skip paths
37
- * nobody guards.
36
+ * The dispatcher uses this to decide which authenticator guards a path, so
37
+ * it must agree with Express exactly: a looser rule would skip paths nobody
38
+ * guards, and a stricter one — a case-sensitive comparison — would hand a
39
+ * path to the wrong authenticator, or to none.
38
40
  *
39
41
  * @param prefix mount prefix
40
42
  * @param path request path
@@ -224,6 +224,31 @@ export default abstract class AuthzBase extends DmPlugin {
224
224
  * branches where what applies differs from the branch above.
225
225
  */
226
226
  abstract ruleBranches(user: string): Promise<string[]>;
227
+ /**
228
+ * The branches that name `dn` one of their administrators.
229
+ *
230
+ * Taking over an administrator account grants everything it administers,
231
+ * so it is not an ordinary entry of the branch it is attached to. Empty
232
+ * for a model that does not name administrators in the directory.
233
+ *
234
+ * @param _dn the entry about to be written
235
+ * @returns the branches it administers
236
+ */
237
+ protected administeredBranches(_dn: string): Promise<string[]>;
238
+ /**
239
+ * Refuse a write on an account some organization names as one of its
240
+ * administrators, unless the caller holds every such branch with write.
241
+ *
242
+ * Judged by attachment alone, the account would otherwise follow the
243
+ * branch it is attached to: a local administrator could claim an
244
+ * administrator account in transit or attached to their own branch, then
245
+ * reset its password and use the rights it carries.
246
+ *
247
+ * @param user the resolved caller
248
+ * @param dn the entry about to be written
249
+ * @throws AuthzRefusedError when the caller does not hold one of them
250
+ */
251
+ private guardAdministratorAccount;
227
252
  /**
228
253
  * Whether a write changes nothing but the organization link and the path
229
254
  * that goes with it: what moving an entry between organizations writes.
@@ -50,6 +50,7 @@ export declare const dynamicOptions: {
50
50
  export type DynamicOptions = OptionsOf<typeof dynamicOptions>;
51
51
  export declare const linid1Options: {
52
52
  authz_local_admin_attribute: import("../../config/options.js").StringOption;
53
+ authz_linid1_cache_ttl: import("../../config/options.js").NumberOption;
53
54
  authz_global_branch: import("../../config/options.js").ArrayOption;
54
55
  authz_global_write_branch: import("../../config/options.js").ArrayOption;
55
56
  authz_global_organization: import("../../config/options.js").StringOption;
@@ -60,6 +61,7 @@ export declare const openidconnectOptions: {
60
61
  oidc_username_claim: import("../../config/options.js").StringOption;
61
62
  oidc_client_id: import("../../config/options.js").StringOption;
62
63
  oidc_client_secret: import("../../config/options.js").StringOption;
64
+ oidc_session_secret: import("../../config/options.js").ArrayOption;
63
65
  base_url: import("../../config/options.js").StringOption;
64
66
  oidc_logout_origins: import("../../config/options.js").ArrayOption;
65
67
  };
@@ -7,6 +7,7 @@
7
7
  * @group Plugins
8
8
  */
9
9
  import type { DM } from '../../bin/index.js';
10
+ import type { Hooks } from '../../hooks.js';
10
11
  import type { BranchPermissions } from '../../config/args.js';
11
12
  import AuthzBase from '../../lib/authz/base.js';
12
13
  interface CachedPermissions {
@@ -16,13 +17,31 @@ interface CachedPermissions {
16
17
  export default class AuthzLinid1 extends AuthzBase {
17
18
  static optionDefinitions: {
18
19
  authz_local_admin_attribute: import("../../config/options.js").StringOption;
20
+ authz_linid1_cache_ttl: import("../../config/options.js").NumberOption;
19
21
  authz_global_branch: import("../../config/options.js").ArrayOption;
20
22
  authz_global_write_branch: import("../../config/options.js").ArrayOption;
21
23
  authz_global_organization: import("../../config/options.js").StringOption;
22
24
  };
23
25
  name: string;
24
26
  permissionsCache: Map<string, CachedPermissions>;
27
+ /**
28
+ * Bumped by every invalidation. A refresh that started before a write may
29
+ * have read the directory before it, so it must not put what it read back
30
+ * into the cache afterwards.
31
+ */
32
+ private permissionsGeneration;
33
+ hooks: AuthzBase['hooks'] & Pick<Hooks, 'ldapmodifydone' | 'ldapdeletedone' | 'ldaprenamedone'>;
34
+ /** What the classes above declared, read once this one is constructed */
35
+ private readonly declaredByBase;
25
36
  constructor(server: DM);
37
+ /** Drop every cached permission lookup. */
38
+ forgetPermissions(): void;
39
+ /**
40
+ * The organizations naming `dn` an administrator. Taking one of those
41
+ * accounts over grants its branches, so the base refuses a caller who
42
+ * does not hold them all.
43
+ */
44
+ protected administeredBranches(dn: string): Promise<string[]>;
26
45
  /**
27
46
  * The administrators of the global organization link any domain too: they
28
47
  * create and delete the domains (`--authz-global-write-branches`), and the
@@ -81,9 +100,12 @@ export default class AuthzLinid1 extends AuthzBase {
81
100
  */
82
101
  private ownPermissions;
83
102
  /**
84
- * Refresh user permissions from LDAP
103
+ * Refresh user permissions from LDAP.
104
+ *
105
+ * @param userDn the administrator's DN
106
+ * @returns the branches this lookup read, whether or not it was cached
85
107
  */
86
- refreshUserPermissions(userDn: string): Promise<void>;
108
+ refreshUserPermissions(userDn: string): Promise<Map<string, BranchPermissions>>;
87
109
  /**
88
110
  * Get list of branches user has access to
89
111
  */
@@ -9,6 +9,7 @@ export default class OpenIDConnect extends AuthBase {
9
9
  oidc_username_claim: import("../../config/options.js").StringOption;
10
10
  oidc_client_id: import("../../config/options.js").StringOption;
11
11
  oidc_client_secret: import("../../config/options.js").StringOption;
12
+ oidc_session_secret: import("../../config/options.js").ArrayOption;
12
13
  base_url: import("../../config/options.js").StringOption;
13
14
  oidc_logout_origins: import("../../config/options.js").ArrayOption;
14
15
  };
@@ -0,0 +1,6 @@
1
+ /**
2
+ * `core/scim` resolves to `plugins/scim.js`, so the documented short name
3
+ * needs a module beside the plugin's own `scim/` directory.
4
+ */
5
+ export * from './scim/scim.js';
6
+ export { default } from './scim/scim.js';
@@ -61,13 +61,6 @@ export default class Calendar extends TwakePlugin {
61
61
  * No configured branch means no branch restriction, as before.
62
62
  */
63
63
  private isInResourceBranch;
64
- /**
65
- * URL of one resource in the WebAdmin API.
66
- *
67
- * The id is a raw RDN value, so it can carry anything a directory accepts:
68
- * `cn=Salle A/B` interpolated as such builds `/resources/Salle A/B`, a path
69
- * naming another resource or none. Percent-encoding keeps it one segment.
70
- */
71
64
  private resourceUrl;
72
65
  /**
73
66
  * Build resource data for Calendar API from LDAP attributes
@@ -659,9 +659,10 @@ npx ldap-rest \
659
659
  --ldap-pwd admin \
660
660
  --ldap-base dc=example,dc=com \
661
661
  --plugin core/auth/openidconnect \
662
- --oidc-issuer https://auth.example.com \
662
+ --oidc-server https://auth.example.com \
663
663
  --oidc-client-id ldap-rest \
664
- --oidc-client-secret your-secret
664
+ --oidc-client-secret your-secret \
665
+ --oidc-session-secret a-session-key-of-32-characters-min
665
666
  ```
666
667
 
667
668
  **Pros:** Standards-based, user-specific tokens, automatic expiration
@@ -101,9 +101,10 @@ OAuth 2.0 / OpenID Connect authentication for modern web applications.
101
101
 
102
102
  ```bash
103
103
  --plugin core/auth/openidconnect \
104
- --oidc-issuer https://auth.example.com \
104
+ --oidc-server https://auth.example.com \
105
105
  --oidc-client-id ldap-rest \
106
- --oidc-client-secret your-secret
106
+ --oidc-client-secret your-secret \
107
+ --oidc-session-secret a-session-key-of-32-characters-min
107
108
  ```
108
109
 
109
110
  > **NB**: the `redirect_uri` is `https://ldap-rest-server/callback`
@@ -369,14 +369,15 @@ Development only, refused with `NODE_ENV=production` — see [Fake Authenticatio
369
369
 
370
370
  #### `core/auth/openidconnect`
371
371
 
372
- | CLI | Env | Default | Description |
373
- | ----------------------- | ------------------------ | ------- | --------------------------------------------------------------------------------------- |
374
- | `--oidc-server` | `DM_OIDC_SERVER` | | OIDC server URL |
375
- | `--oidc-client-id` | `DM_OIDC_CLIENT_ID` | | OIDC Client ID |
376
- | `--oidc-client-secret` | `DM_OIDC_CLIENT_SECRET` | | OIDC Client Secret |
377
- | `--oidc-username-claim` | `DM_OIDC_USERNAME_CLAIM` | `sub` | Claim naming the caller, published as `req.userName` (see [OIDC](plugins/auth/oidc.md)) |
378
- | `--oidc-logout-origins` | `DM_OIDC_LOGOUT_ORIGINS` | `[]` | Other origins a console may log out from (see [OIDC](plugins/auth/oidc.md#logout)) |
379
- | `--base-url` | `DM_BASE_URL` | | Public URL for callbacks |
372
+ | CLI | Env | Default | Description |
373
+ | -------------------------------------------------- | ------------------------ | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
374
+ | `--oidc-server` | `DM_OIDC_SERVER` | | OIDC server URL |
375
+ | `--oidc-client-id` | `DM_OIDC_CLIENT_ID` | | OIDC Client ID |
376
+ | `--oidc-client-secret` | `DM_OIDC_CLIENT_SECRET` | | OIDC Client Secret |
377
+ | `--oidc-session-secret` / `--oidc-session-secrets` | `DM_OIDC_SESSION_SECRET` | | Key of the session cookie; required, and must differ from the client secret. Several values rotate sessions (see [OIDC](plugins/auth/oidc.md#session-key)) |
378
+ | `--oidc-username-claim` | `DM_OIDC_USERNAME_CLAIM` | `sub` | Claim naming the caller, published as `req.userName` (see [OIDC](plugins/auth/oidc.md)) |
379
+ | `--oidc-logout-origins` | `DM_OIDC_LOGOUT_ORIGINS` | `[]` | Other origins a console may log out from (see [OIDC](plugins/auth/oidc.md#logout)) |
380
+ | `--base-url` | `DM_BASE_URL` | | Public URL for callbacks |
380
381
 
381
382
  #### `core/bcl`
382
383
 
@@ -458,6 +459,7 @@ whichever of the two names it selects. A value other than `req.user` or
458
459
  | CLI | Env | Default | Description |
459
460
  | --------------------------------------------------------------- | -------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
460
461
  | `--authz-local-admin-attribute` | `DM_AUTHZ_LOCAL_ADMIN_ATTRIBUTE` | `twakeLocalAdminLink` | Local admin attribute |
462
+ | `--authz-linid1-cache-ttl` | `DM_AUTHZ_LINID1_CACHE_TTL` | `300` | Seconds a user's resolved permissions are cached; a write touching the admin attribute clears the cache at once |
461
463
  | `--authz-global-branch` / `--authz-global-branches` | `DM_AUTHZ_GLOBAL_BRANCHES` | | Branches outside the tree only the administrators of `--authz-global-organization` read and write, [details](plugins/auth/authz-linid1.md#global-branches) |
462
464
  | `--authz-global-write-branch` / `--authz-global-write-branches` | `DM_AUTHZ_GLOBAL_WRITE_BRANCHES` | | Branches outside the tree every administrator reads and only the administrators of `--authz-global-organization` write and delete in, [details](plugins/auth/authz-linid1.md#global-branches) |
463
465
  | `--authz-global-organization` | `DM_AUTHZ_GLOBAL_ORGANIZATION` | `--ldap-top-organization` | The organization holding the global branches and global write branches |
@@ -208,7 +208,7 @@ ldap-rest \
208
208
  --plugin 'core/ldap/raw:raw-admins:{"api_prefix":"/api/admin"}'
209
209
  ```
210
210
 
211
- A token presented on `/api/admin` is refused, and a session presented on `/api/m` is refused too: each authentication plugin only sees the branch it was mounted on. Prefixes match on segment boundaries, so `/api/m` guards `/api/m` and `/api/m/entry` but never `/api/machines`.
211
+ A token presented on `/api/admin` is refused, and a session presented on `/api/m` is refused too: each authentication plugin only sees the branch it was mounted on. Prefixes match on segment boundaries and ignore letter case, as the routes do, so `/api/m` guards `/api/m` and `/api/M/entry` but never `/api/machines`.
212
212
 
213
213
  A prefix must be a string starting with `/`. Anything else — a number left in the JSON, an empty entry, a prefix without its leading slash — refuses to start rather than being skipped: a dropped entry shrinks what the plugin guards, which is the failure that leaves a branch open while the configuration still reads as if it were covered. `/` is not a prefix but the whole server, so a list containing it makes the plugin a catch-all: `["/", "/api/admin"]` guards everything, not only `/api/admin`.
214
214
 
@@ -95,6 +95,13 @@ may claim it into one they manage, and the first to claim it takes it.
95
95
  claiming it may carry other changes to the entry — its password, say —
96
96
  judged with it: the entry lands in the organization that write is
97
97
  allowed in.
98
+ - An account some organization names in
99
+ `--authz-local-admin-attribute` is not created, claimed, moved, changed or
100
+ deleted by an administrator who does not write **every** organization
101
+ naming it, including when the DN is one the organization still names with
102
+ no entry behind it. Taking such an account over would hand over the
103
+ branches it administers, which is why it is judged on those branches
104
+ rather than on the one it is attached to.
98
105
  - **Putting** one in transit asks for write on the organization it leaves,
99
106
  and nothing on the transit branch: an administrator only lets go of what
100
107
  they manage. Handing an account over to an organization one does not manage
@@ -241,7 +248,17 @@ No configuration file needed - permissions are read from LDAP. Simply load the p
241
248
 
242
249
  **Optional Cache Configuration:**
243
250
 
244
- The plugin caches permissions for 5 minutes by default. This is hardcoded but can be modified in the source if needed.
251
+ The plugin caches permissions for 5 minutes by default:
252
+
253
+ ```bash
254
+ --authz-linid1-cache-ttl 60
255
+ ```
256
+
257
+ A write through this server that touches the admin attribute clears the
258
+ cache at once, so a revocation does not wait for the TTL. A change made
259
+ directly in the directory waits for it. With several instances, a write
260
+ clears the cache of the instance that served it; another instance's cache
261
+ waits for the TTL, like a change made directly in the directory.
245
262
 
246
263
  ### The identity has to be a `uid`
247
264
 
@@ -264,7 +281,7 @@ An identity naming **several** entries is refused (403) whatever that
264
281
  option says, and a warning gives the number of entries. A lookup the
265
282
  directory fails fails the request and is not cached.
266
283
 
267
- Both answers are cached for the plugin's TTL (5 minutes), negatives
284
+ Both answers are cached for the plugin's TTL (5 minutes by default), negatives
268
285
  included, so a mismatch does not turn every request into a directory search.
269
286
  The TTL cuts both ways: an identity added to the directory after being
270
287
  refused waits it out, and an administrator whose **own entry is renamed or
@@ -282,7 +299,7 @@ For each LDAP operation, the plugin:
282
299
 
283
300
  1. **Resolves user DN** - Converts username to LDAP DN
284
301
  2. **Searches for permissions** - Finds all organizations where user is in `twakeLocalAdminLink`
285
- 3. **Caches results** - Stores permissions for 5 minutes
302
+ 3. **Caches results** - Stores permissions for the configured TTL (5 minutes by default)
286
303
  4. **Checks branch access** - Verifies if requested branch matches authorized organizations
287
304
  5. **Checks link attributes** - For entries with `twakeDepartmentLink`, checks if link points to authorized org
288
305
 
@@ -458,7 +475,7 @@ Response: 403 Forbidden
458
475
 
459
476
  Permissions are cached to reduce LDAP queries:
460
477
 
461
- - **Cache TTL**: 5 minutes (hardcoded)
478
+ - **Cache TTL**: 5 minutes by default (`--authz-linid1-cache-ttl`)
462
479
  - **Per-user cache**: Each user's permissions cached separately
463
480
  - **Automatic refresh**: Cache expires after TTL
464
481
  - **On-demand refresh**: New permissions fetched on cache miss
@@ -538,7 +555,7 @@ member: uid=employee1,ou=users,dc=example,dc=com
538
555
  - **Centralized control** - All permissions in LDAP, no config files to sync
539
556
  - **Audit trail** - LDAP modifications can be logged/audited
540
557
  - **Consistency** - Single source of truth for permissions
541
- - **Real-time updates** - Changes effective after cache expiry (5 min max)
558
+ - **Real-time updates** - A write through this server takes effect at once; a change made directly in the directory waits for the cache TTL
542
559
 
543
560
  ### Permission Model
544
561
 
@@ -559,8 +576,8 @@ This plugin only handles **authorization**. Combine with an authentication plugi
559
576
 
560
577
  ### Cache Security
561
578
 
562
- - **Cache delays permission revocation** - Up to 5 minutes after removing user from `twakeLocalAdminLink`
563
- - **Cache per user** - Removing one user doesn't invalidate others
579
+ - **Cache delays permission revocation** - Up to the TTL after removing user from `twakeLocalAdminLink` directly in the directory; a write through this server clears it at once
580
+ - **Cache per user** - A write touching the admin attribute clears every cached entry, since the hook does not resolve which users it affects
564
581
  - **No cache persistence** - Cache cleared on server restart
565
582
 
566
583
  ## Troubleshooting
@@ -585,7 +602,7 @@ This plugin only handles **authorization**. Combine with an authentication plugi
585
602
 
586
603
  2. Check user DN matches exactly (spaces, case, order)
587
604
 
588
- 3. Wait up to 5 minutes for cache to expire after adding user
605
+ 3. Wait up to the cache TTL after adding the user directly in the directory
589
606
 
590
607
  4. Check user is authenticated (verify `req.user` is set)
591
608
 
@@ -621,7 +638,7 @@ Added user to `twakeLocalAdminLink` but they still get access denied.
621
638
 
622
639
  **Solutions:**
623
640
 
624
- 1. Wait up to 5 minutes for cache to expire
641
+ 1. Wait up to the cache TTL, or empty the cache with a write through this server
625
642
  2. Restart server to clear all caches immediately
626
643
  3. Verify LDAP modification succeeded:
627
644
  ```bash
@@ -667,6 +684,7 @@ User jdoe not found in LDAP
667
684
  --oidc-server "https://auth.example.com" \
668
685
  --oidc-client-id "ldap-rest" \
669
686
  --oidc-client-secret "secret" \
687
+ --oidc-session-secret "a-session-key-of-32-characters-min" \
670
688
  --base-url "https://api.example.com" \
671
689
  --ldap-top-organization "ou=organization,dc=example,dc=com"
672
690
  ```
@@ -690,7 +708,7 @@ User jdoe not found in LDAP
690
708
  | Read-only access | No (always full access) | Yes (separate read/write/delete) |
691
709
  | Cross-branch | Yes (via `twakeDepartmentLink`) | Yes (if configured) |
692
710
  | Dynamic updates | Yes (via LDAP) | No (requires restart) |
693
- | Cache | 5 min (hardcoded) | Configurable TTL |
711
+ | Cache | 5 min (`--authz-linid1-cache-ttl`) | `--authz-per-branch-cache-ttl` |
694
712
  | Use case | Organization-based delegated admin | Fine-grained access control |
695
713
 
696
714
  **When to use authzLinid1:**
@@ -652,6 +652,7 @@ User should inherit group permissions but doesn't.
652
652
  --oidc-server "https://auth.example.com" \
653
653
  --oidc-client-id "ldap-rest" \
654
654
  --oidc-client-secret "secret" \
655
+ --oidc-session-secret "a-session-key-of-32-characters-min" \
655
656
  --base-url "https://api.example.com" \
656
657
  --authz-per-branch-config '{
657
658
  "users": {
@@ -9,6 +9,7 @@ OAuth 2.0 / OpenID Connect authentication for modern identity providers.
9
9
  --oidc-server "https://auth.example.com" \
10
10
  --oidc-client-id "ldap-rest-client" \
11
11
  --oidc-client-secret "client-secret-here" \
12
+ --oidc-session-secret "a-session-key-of-32-characters-min" \
12
13
  --base-url "https://api.example.com"
13
14
  ```
14
15
 
@@ -18,9 +19,28 @@ OAuth 2.0 / OpenID Connect authentication for modern identity providers.
18
19
  DM_OIDC_SERVER="https://auth.example.com"
19
20
  DM_OIDC_CLIENT_ID="ldap-rest-client"
20
21
  DM_OIDC_CLIENT_SECRET="client-secret-here"
22
+ DM_OIDC_SESSION_SECRET="a-session-key-of-32-characters-min"
21
23
  DM_BASE_URL="https://api.example.com"
22
24
  ```
23
25
 
26
+ ## Session key
27
+
28
+ `--oidc-session-secret` is the key of the `appSession` cookie, and the
29
+ plugin refuses to start without it. It must differ from
30
+ `--oidc-client-secret`: the cookie holds the whole session, so whoever
31
+ knows its key can forge an administrator's, offline and without going
32
+ through the provider. The client secret is known to the provider's
33
+ administrators and lives in the client registration, which is not the same
34
+ set of people as the directory's administrators.
35
+
36
+ Give the cookie a key of its own, at least 32 characters. `openssl rand -hex
37
+ 32` gives one whose alphabet is safe for `DM_OIDC_SESSION_SECRET`, which
38
+ splits on `,` or `;` (and on newlines) into several keys. To rotate it,
39
+ repeat the option or give a comma- or newline-separated list to
40
+ `--oidc-session-secrets` or `DM_OIDC_SESSION_SECRET` (`;` when a key holds
41
+ a comma): the library encrypts with the first key and accepts the others, so
42
+ sessions opened before the rotation keep working until they expire.
43
+
24
44
  ## Prerequisites
25
45
 
26
46
  1. **OpenID Provider**: [Lemonldap-NG](https://lemonldap-ng.org), Keycloak, Auth0, Okta, Azure AD, etc.
@@ -83,11 +83,11 @@ The plugin uses the following WebAdmin API endpoints:
83
83
  - `PATCH /registeredUsers?id={id}` - Update a registered user's email, first and last name
84
84
  - `POST /users/{mail}?action=deleteData` - Delete a user's data (see `deleteUserData`)
85
85
 
86
- The resource id goes into the path percent-encoded, so a `/` or a `#` in it
87
- cannot build a path naming something else. The user's address is left as it is
88
- written — `user@example.com`, `@` being legal in a path segment — which is what
89
- `plugins/twake/james` does with the same WebAdmin; whether the two should
90
- encode it is issue #175.
86
+ The resource id and the user's address each go into the path as one encoded
87
+ segment, so a `/`, `#`, `?` or `%` in either cannot build a path naming
88
+ something else. `@` stays as written: it is legal in a path segment and does
89
+ not delimit one, which keeps an ordinary address in the form WebAdmin already
90
+ receives.
91
91
 
92
92
  ### Registered Users
93
93
 
@@ -985,7 +985,7 @@ class CustomJames extends James {
985
985
  // Custom logic for department changes
986
986
  await this._try(
987
987
  'onLdapDepartmentChange',
988
- `${this.config.james_webadmin_url}/users/${mail}/metadata/department`,
988
+ this.webadminPath('users', mail, 'metadata', 'department'),
989
989
  'PUT',
990
990
  dn,
991
991
  newDept,
@@ -4,6 +4,25 @@ What to check before deploying, newest first. Only releases that need a
4
4
  decision or a configuration change appear here; see the
5
5
  [CHANGELOG](../../CHANGELOG.md) for everything else.
6
6
 
7
+ ## Unreleased
8
+
9
+ ### OpenID Connect requires a session key of its own
10
+
11
+ **Who is affected:** every deployment loading `core/auth/openidconnect`.
12
+
13
+ `--oidc-session-secret` / `DM_OIDC_SESSION_SECRET` is now required, and the
14
+ plugin refuses to start without it or with a value equal to, or a fragment
15
+ of, `DM_OIDC_CLIENT_SECRET`. The cookie holds the whole session, so its key
16
+ must not be the client secret known to the provider's administrators. Set a
17
+ key of its own, at least 32 characters; see
18
+ [OIDC](plugins/auth/oidc.md#session-key). Give it to every replica.
19
+
20
+ Sessions opened before the upgrade were encrypted with the client secret
21
+ and are refused: every user signs in again. During a rolling upgrade, old
22
+ and new replicas reject each other's cookies until the rollout completes.
23
+ A later rotation keeps the previous key in the list while its sessions
24
+ expire ([OIDC](plugins/auth/oidc.md#session-key)).
25
+
7
26
  ## From 2.0.3
8
27
 
9
28
  ### An `authzDynamic` flag must be a JSON boolean
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ldap-rest",
3
- "version": "2.0.5",
3
+ "version": "2.1.1",
4
4
  "description": "Light directory manager",
5
5
  "keywords": [
6
6
  "Identity-Manager"
@@ -188,6 +188,10 @@
188
188
  "import": "./dist/plugins/rabbitmq.js",
189
189
  "types": "./dist/src/plugins/rabbitmq.d.ts"
190
190
  },
191
+ "./plugin-scim": {
192
+ "import": "./dist/plugins/scim.js",
193
+ "types": "./dist/src/plugins/scim.d.ts"
194
+ },
191
195
  "./plugin-scim-baseresolver": {
192
196
  "import": "./dist/plugins/scim/baseResolver.js",
193
197
  "types": "./dist/src/plugins/scim/baseResolver.d.ts"
@@ -836,6 +840,9 @@
836
840
  "test:one": "mocha --config .mocharc-one.json"
837
841
  },
838
842
  "overrides": {
843
+ "@microsoft/api-extractor": {
844
+ "minimatch": "^11.0.5"
845
+ },
839
846
  "diff": ">=8.0.3",
840
847
  "glob": ">=11.1.0",
841
848
  "minimatch": "^10.2.3",