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.
- package/dist/abstract/twakePlugin.js +1 -1
- package/dist/bin/index.js +1 -1
- package/dist/lib/auth/base.js +1 -1
- package/dist/lib/authz/base.js +1 -1
- package/dist/lib/authz/composition.js +1 -1
- package/dist/plugins/auth/auth.options.js +1 -1
- package/dist/plugins/auth/authzLinid1.js +1 -1
- package/dist/plugins/auth/openidconnect.js +1 -1
- package/dist/plugins/scim.js +1 -0
- package/dist/plugins/twake/calendar.js +1 -1
- package/dist/plugins/twake/james.js +1 -1
- package/dist/plugins/twake/migrationProxy.js +1 -1
- package/dist/src/abstract/twakePlugin.d.ts +25 -0
- package/dist/src/config/args.d.ts +3 -0
- package/dist/src/lib/auth/base.d.ts +7 -5
- package/dist/src/lib/authz/base.d.ts +25 -0
- package/dist/src/plugins/auth/auth.options.d.ts +2 -0
- package/dist/src/plugins/auth/authzLinid1.d.ts +24 -2
- package/dist/src/plugins/auth/openidconnect.d.ts +1 -0
- package/dist/src/plugins/scim.d.ts +6 -0
- package/dist/src/plugins/twake/calendar.d.ts +0 -7
- package/docs/client-development/api/reference.md +3 -2
- package/docs/client-development/api/rest-api.md +3 -2
- package/docs/usage/configuration.md +10 -8
- package/docs/usage/plugins/auth/README.md +1 -1
- package/docs/usage/plugins/auth/authz-linid1.md +28 -10
- package/docs/usage/plugins/auth/authz-per-branch.md +1 -0
- package/docs/usage/plugins/auth/oidc.md +20 -0
- package/docs/usage/plugins/integrations/calendar.md +5 -5
- package/docs/usage/plugins/integrations/james-mail.md +1 -1
- package/docs/usage/upgrading.md +19 -0
- 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};
|
package/dist/lib/auth/base.js
CHANGED
|
@@ -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
|
|
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};
|
package/dist/lib/authz/base.js
CHANGED
|
@@ -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.
|
|
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"})}),
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
|
36
|
-
* it must agree with Express exactly
|
|
37
|
-
*
|
|
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<
|
|
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
|
};
|
|
@@ -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-
|
|
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-
|
|
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
|
|
373
|
-
|
|
|
374
|
-
| `--oidc-server`
|
|
375
|
-
| `--oidc-client-id`
|
|
376
|
-
| `--oidc-client-secret`
|
|
377
|
-
| `--oidc-
|
|
378
|
-
| `--oidc-
|
|
379
|
-
| `--
|
|
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/
|
|
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
|
|
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 (
|
|
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** -
|
|
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
|
|
563
|
-
- **Cache per user** -
|
|
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
|
|
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
|
|
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 (
|
|
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
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
988
|
+
this.webadminPath('users', mail, 'metadata', 'department'),
|
|
989
989
|
'PUT',
|
|
990
990
|
dn,
|
|
991
991
|
newDept,
|
package/docs/usage/upgrading.md
CHANGED
|
@@ -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.
|
|
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",
|