ldap-rest 2.0.0 → 2.0.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/README.md +8 -1
- package/dist/bin/index.js +1 -1
- package/dist/src/bin/index.d.ts +20 -4
- package/docs/README.md +9 -0
- package/docs/architecture/README.md +180 -0
- package/docs/architecture/adr/0001-plugin-load-order.md +50 -0
- package/docs/architecture/adr/0002-authentication-dispatch.md +43 -0
- package/docs/architecture/adr/0003-authorization-composition.md +59 -0
- package/docs/architecture/adr/0004-fail-closed-request-context.md +45 -0
- package/docs/architecture/adr/0005-hook-contract.md +54 -0
- package/docs/architecture/adr/0006-search-cache.md +49 -0
- package/docs/architecture/adr/0007-trash-and-tombstone.md +69 -0
- package/docs/architecture/adr/0008-withconfig-views.md +58 -0
- package/docs/development/contracts.md +16 -6
- package/docs/development/modularization-plan.md +197 -0
- package/docs/plugin-development/hooks.md +2 -1
- package/docs/usage/operations.md +6 -3
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -81,7 +81,7 @@ See **[Configuration Guide](./docs/usage/configuration.md)** for all CLI options
|
|
|
81
81
|
|
|
82
82
|
## Documentation
|
|
83
83
|
|
|
84
|
-
Documentation is organized into
|
|
84
|
+
Documentation is organized into 4 categories:
|
|
85
85
|
|
|
86
86
|
### [Usage](./docs/usage/README.md)
|
|
87
87
|
|
|
@@ -110,6 +110,13 @@ Integrate LDAP-Rest into your web applications.
|
|
|
110
110
|
- **[Examples](./docs/client-development/examples/README.md)** - React, Vue.js, Vanilla JS
|
|
111
111
|
- **[Browser Demos](./examples/web/)** - Interactive demos (TOTP client, LDAP tree viewer)
|
|
112
112
|
|
|
113
|
+
### [Architecture](./docs/architecture/README.md)
|
|
114
|
+
|
|
115
|
+
How the server is put together and why.
|
|
116
|
+
|
|
117
|
+
- **[Overview](./docs/architecture/README.md)** - Module map, life of a request, life of a write
|
|
118
|
+
- **[Decision records](./docs/architecture/adr/)** - The decisions the code embodies
|
|
119
|
+
|
|
113
120
|
## Copyright and license
|
|
114
121
|
|
|
115
122
|
[](https://linagora.com)
|
package/dist/bin/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{fileURLToPath as e}from"url";import{join as t,dirname as i}from"path";import s from"express";import r from"body-parser";import{ConfigParser as o}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 d}from"../logger/winston.js";import{setLogger as u,setDebug as c,errorResponse as g,messageOf as p}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 O}from"../lib/requestContext.js";import{requestId as S}from"../lib/changeContext.js";import{mountHealth as M,HEALTH_PATHS as x}from"../lib/health.js";import{httpRequestDuration as D,routeTemplate as C,secondsSince as E,unhandledRejections as _}from"../lib/metrics.js";import{registerProcessShutdown as A,shutdownTimeout as R}from"../lib/shutdown.js";import T from"../plugins/priority.json.js";const rejectEmptyMediaType=(e,t,i)=>{const s=e.headers["content-type"];(e=>void 0!==e.headers["transfer-encoding"]||!Number.isNaN(Number(e.headers["content-length"])))(e)&&"string"==typeof s&&""===s.split(";",1)[0].trim()?i(new y("Content-Type names no media type")):i()},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;operationSequence;logger;authenticators=[];_authState={dispatcherMounted:!1};_errorMiddlewareSetup=!1;bodyParsers=s.Router();_composed=!1;get composed(){return this._composed}life={unhandledRejections:0};inFlight=new Set;get stopping(){return void 0!==this.life.shutdown}get unhandledRejections(){return this.life.unhandledRejections}_errorMiddleware;pluginLoads=new Map;unknownCliOptions;deprecationWarnings;authzHooks=new WeakSet;constructor(){const e=new o(h);this.config=e.parse(),this.unknownCliOptions=e.unknownOptions,this.deprecationWarnings=e.deprecationWarnings,this.app=s(),this.app.disable("x-powered-by"),this.app.use((e,t,i)=>{const s=process.hrtime.bigint();t.setHeader("X-Content-Type-Options","nosniff"),this.stopping&&t.setHeader("Connection","close"),this.inFlight.add(t),t.once("finish",()=>{D.observe({method:e.method,route:C(e),status:String(t.statusCode)},E(s)),this.stopping&&this.server?.closeIdleConnections()}),t.once("close",()=>this.inFlight.delete(t)),i()}),this.app.use((e,t,i)=>{const s=S(e);t.setHeader("X-Request-Id",s),k({requestId:s},i)}),this.app.use(rejectEmptyMediaType),this.app.use(this.bodyParsers),this.app.use(r.json({verify:a})),this.app.use(r.urlencoded({extended:!0,verify:a})),M(this.app,this),this.logger=d(this.config);for(const e of this.deprecationWarnings)this.logger.warn(e);this.ldap=new l(this),u(this.logger),c(this.config.debug),this.operationSequence=0;const t=[];if(this.config.plugin){const e=this.config.plugin.find(e=>e.includes("configApi"));let i=this.config.plugin.filter(e=>!e.includes("configApi"));const s=this.logger,r=(async()=>{for(const e of T){const t=this.resolveModule(DM.moduleOf(e)),r=i.filter(e=>this.resolveModule(DM.moduleOf(e))===t);if(0!==r.length){i=i.filter(e=>!r.includes(e));for(const e of r)s.debug(`Loading ${e} in the priority pass`),await this.loadPlugin(e)}}})();t.push(r),t.push(r.then(async()=>{const e=i.map(e=>(s.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,i)=>{t.length>0?Promise.all(t).then(async()=>{this.setupErrorMiddleware(),this.warnUnauthenticatedRoutes(),await m(this),w(this),await this.learnAttributeTypes(),this._composed=!0,this.reportUnknownOptions(),this.afterLoad(),e()}).catch(e=>i(new Error("Error loading plugins: "+e))):(this.setupErrorMiddleware(),this._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,"_"))]))),i=[...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===i.length)return;const s=`Unknown configuration: ${i.join(", ")} — no declared option reads it`;if(this.config.strict_options)throw new Error(s);this.logger.warn(s)}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,i)=>{this.dispatchAuth(e,t,i)}))}registerAuthenticator(e){this.mountAuthDispatcherNow(),this.authenticators.includes(e)||this.authenticators.push(e)}registerBodyParser(e,t){this.bodyParsers.use(e,t)}dispatchAuth(e,t,i){let s=-1,r=[];for(const t of this.authenticators)for(const i of t.pathPrefixes)f(i,e.path)&&(i.length>s?(s=i.length,r=[t]):i.length!==s||r.includes(t)||r.push(t));if(s<0&&(r=this.authenticators.filter(e=>0===e.pathPrefixes.length)),0===r.length)return i();const finish=s=>{if(s>=r.length)return O(e,i);const o=r[s];if(!o.afterChain)return finish(s+1);o.afterChain(e,t,()=>finish(s+1))},o=[];e.authenticators=o;const run=i=>{if(i>=r.length)return finish(0);const s=r[i];s.authenticate(e,t,()=>{s.vouchedFor(e)&&o.push(s.name),run(i+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 i of e){if(!i.pathPrefixes||0===i.pathPrefixes.length)return[];t.push(...i.pathPrefixes)}const i=this.routerStack(),s=new Set;for(const e of i||[]){const t=e.route?.path;"string"==typeof t?s.add(t):Array.isArray(t)&&t.forEach(e=>"string"==typeof e&&s.add(e))}const r=[...s].filter(e=>!x.includes(e)&&!t.some(t=>e===t||e.startsWith(`${t}/`))),o=Object.values(this.loadedPlugins).some(e=>e.roles?.includes("authz"));return r.length>0&&this.logger.warn(`Authentication is restricted to ${t.join(", ")}, so these routes are served without authentication`+(o?" nor authorization":"")+`: ${r.sort().join(", ")}`),r.sort()}setupErrorMiddleware(){if(this._errorMiddlewareSetup&&this._errorMiddleware){const e=this.routerStack();if(e){const t=e.findIndex(e=>e.handle===this._errorMiddleware);-1!==t&&e.splice(t,1)}}this._errorMiddleware=(e,t,i,s)=>{const{status:r,body:o}=g(e,this.config.debug);if(r<500)return this.logger.warn(`Client error ${r} in request ${t.method} ${t.path}: ${o.error}`),void(i.headersSent||i.status(r).json(o));this.logger.error(`Server error ${r} in request ${t.method} ${t.path}: ${p(e)}`,{stack:stackChain(e),url:t.url,method:t.method}),i.headersSent||i.status(r).json(o)},this.app.use(this._errorMiddleware),this._errorMiddlewareSetup=!0}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++,_.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,i=>{i?(this.logger.error(`Error starting server: ${i}`),t(i)):(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(),i=Math.max(1e3,e-5e3);this.logger.notice(`Shutting down, ${i} ms for what is in flight`);const s=this.server;if(s){const e=new Promise(e=>s.close(()=>e()));s.closeIdleConnections();for(const e of this.inFlight)e.headersSent||e.setHeader("Connection","close");await DM.within(e,i),s.closeAllConnections()}const r=new Map;for(const[e,t]of Object.entries(this.loadedPlugins))r.set(e,Promise.resolve().then(()=>t.close?.()).catch(t=>{this.logger.warn(`${e}: close failed: ${t instanceof Error?t.message:String(t)}`)}).finally(()=>r.delete(e)));const o=Promise.all(r.values()).then(()=>this.ldap.close().catch(()=>{})),n=Math.max(1e3,e-(Date.now()-t)-1e3);await DM.within(o,n)||(this.logger.warn(`Shut down with a close still pending: ${[...r.keys()].join(", ")||"the directory connections"}`),this.ldap.close().catch(()=>{})),this.logger.notice("Shut down")}static async within(e,t){let i;const s=new Promise(e=>{i=setTimeout(()=>e(!1),t)});try{return await Promise.race([e.then(()=>!0),s])}finally{clearTimeout(i)}}shutdownOnSignals(){R.ms=this.config.shutdown_timeout,A(()=>this.shutdown())}static moduleOf(e){const t=e.indexOf(":");return-1===t?e:e.substring(0,t)}resolveModule(s){return s.startsWith("core/")?s.replace("core/",t(i(e(import.meta.url)),"..","plugins")+"/").replace(/$/,".js"):s.startsWith(".")?e(new URL(s,import.meta.url)):s}loadPlugin(e){let t,i;if(/:/.test(e)){let s=e.substring(e.indexOf(":")+1);if(e=e.substring(0,e.indexOf(":")),/:/.test(s)){t=s.substring(0,s.indexOf(":")),t||(t=void 0),s=s.substring(s.indexOf(":")+1);try{i=JSON.parse(s),"object"!=typeof i?(this.logger.error(`Overrides for plugin ${e} are not valid: ${s}`),i=void 0):this.logger.debug(`Overrides for plugin ${t||e}: ${s}`)}catch(t){this.logger.error(`Failed to parse overrides for plugin ${e}: ${t}, using ${s}`),i=void 0}}else t=s,t||(t=void 0)}else t=void 0;const s=i?void 0:`${this.resolveModule(e)}\0${t??""}`,r=void 0===s?void 0:this.pluginLoads.get(s);if(r)return this.logger.debug(`Plugin ${e} already loaded, skipping`),r;const o=this.importPlugin(e,t,i);if(void 0!==s){this.pluginLoads.set(s,o);const forget=()=>{this.pluginLoads.get(s)===o&&this.pluginLoads.delete(s)};o.then(e=>e||forget(),forget)}return o}importPlugin(e,t,i){return this.logger.debug(`Loading plugin ${e}`),e.startsWith("core/")&&(e=this.resolveModule(e)),new Promise((s,r)=>{import(e).then(async o=>{let n;if(o&&o.default&&(o=o.default),i){const e={...this.config,...i};n=new o(this.withConfig(e))}else n=new o(this);if(!n)return r(new Error(`Unable to load ${e}`));s(await this.registerPlugin(e,n,t)),this.logger.debug(`Plugin ${n.name} loaded`)}).catch(t=>r(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,i){if(t.name||(t.name=e),i&&(t.name=i),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._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._errorMiddlewareSetup&&this.setupErrorMiddleware()),t.hooks)for(const e in t.hooks){this.logger.debug(`Plugin ${t.name} has hook ${e}, registering it`);const i=t.hooks[e];if(void 0===i)continue;if("function"!=typeof i)throw new Error(`Plugin ${t.name}: hook ${e} is invalid`);const s=e;if(j(i,t.name,e),t.roles?.includes("authz")&&L.includes(s)){const e=this.hookBus.subscribers(s),t=e.findIndex(e=>!this.authzHooks.has(e));this.hookBus.insert(s,i,t<0?e.length:t),this.authzHooks.add(i)}else this.hookBus.subscribe(s,i)}return this.loadedPlugins[t.name]=t,this.registerServices(t),!0}registerServices(e){for(const t of e.provides??[]){const i=this.serviceOwners.get(t.token);void 0===i?(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 ${i}; 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=>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};
|
package/dist/src/bin/index.d.ts
CHANGED
|
@@ -53,7 +53,14 @@ export declare class DM {
|
|
|
53
53
|
/** The plugin that provided each service, for the duplicate warning. */
|
|
54
54
|
private readonly serviceOwners;
|
|
55
55
|
ldap: ldapActions;
|
|
56
|
-
|
|
56
|
+
/**
|
|
57
|
+
* The next operation number, shared by the `withConfig` views. An accessor
|
|
58
|
+
* over an object because a field would be copied into each view — which
|
|
59
|
+
* would then count on its own — while an object is copied by reference.
|
|
60
|
+
*/
|
|
61
|
+
private readonly _operations;
|
|
62
|
+
get operationSequence(): number;
|
|
63
|
+
set operationSequence(value: number);
|
|
57
64
|
logger: winston.Logger;
|
|
58
65
|
/** Authentication plugins, in registration order */
|
|
59
66
|
authenticators: AuthBase[];
|
|
@@ -66,15 +73,25 @@ export declare class DM {
|
|
|
66
73
|
* shared.
|
|
67
74
|
*/
|
|
68
75
|
private _authState;
|
|
69
|
-
|
|
76
|
+
/**
|
|
77
|
+
* The mounted error middleware, if any. In an object, as `_authState` is: a
|
|
78
|
+
* view made during loading would copy a bare `undefined` field, so a route
|
|
79
|
+
* it registers after `ready` would land behind the handler — the one place
|
|
80
|
+
* Express never routes an error to.
|
|
81
|
+
*/
|
|
82
|
+
private readonly _errorState;
|
|
70
83
|
/** The parsers plugins mount ahead of the global ones, see registerBodyParser */
|
|
71
84
|
private readonly bodyParsers;
|
|
72
85
|
/**
|
|
73
86
|
* Whether the plugins of the configuration are loaded and their
|
|
74
87
|
* composition checked. A plugin registered after that — a server
|
|
75
88
|
* assembled by hand — is checked on its own as it arrives.
|
|
89
|
+
*
|
|
90
|
+
* In an object, as `_authState` is: a plain flag is copied into each
|
|
91
|
+
* `withConfig` view, and a view made during loading would keep `false` and
|
|
92
|
+
* let a plugin registered through it skip the composition check.
|
|
76
93
|
*/
|
|
77
|
-
private
|
|
94
|
+
private readonly _compositionState;
|
|
78
95
|
/** Every plugin loaded and the composition checked */
|
|
79
96
|
get composed(): boolean;
|
|
80
97
|
/**
|
|
@@ -90,7 +107,6 @@ export declare class DM {
|
|
|
90
107
|
get stopping(): boolean;
|
|
91
108
|
/** Rejections nobody handled since the start, logged and counted */
|
|
92
109
|
get unhandledRejections(): number;
|
|
93
|
-
private _errorMiddleware?;
|
|
94
110
|
/**
|
|
95
111
|
* The loads of `--plugin` entries without overrides, keyed by resolved
|
|
96
112
|
* module and name. Regular plugins load in parallel and load their own
|
package/docs/README.md
CHANGED
|
@@ -34,12 +34,21 @@ Integrate LDAP-Rest into your web applications.
|
|
|
34
34
|
- **[JSON Schemas](client-development/schemas/README.md)** - Schema structure and validation
|
|
35
35
|
- **[Examples](client-development/examples/README.md)** - React, Vue.js, Vanilla JS integration
|
|
36
36
|
|
|
37
|
+
### [Architecture](architecture/README.md)
|
|
38
|
+
|
|
39
|
+
How the server is put together.
|
|
40
|
+
|
|
41
|
+
- **[Overview](architecture/README.md)** - Module map, life of a request, life of a write
|
|
42
|
+
- **[Decision records](architecture/adr/)** - Why the code is the way it is
|
|
43
|
+
|
|
37
44
|
### [Development](development/contracts.md)
|
|
38
45
|
|
|
39
46
|
Work on ldap-rest itself.
|
|
40
47
|
|
|
41
48
|
- **[Published contracts](development/contracts.md)** - What breaks a
|
|
42
49
|
contract, the snapshots that detect it and the deprecation window.
|
|
50
|
+
- **[Modularization plan](development/modularization-plan.md)** - How the
|
|
51
|
+
`@ldap-rest/*` split and the remaining code-quality issues are sequenced.
|
|
43
52
|
|
|
44
53
|
## API stability
|
|
45
54
|
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# Architecture
|
|
2
|
+
|
|
3
|
+
How the server is put together, and the two paths that carry most of the
|
|
4
|
+
work. Every design decision the code already embodies is recorded once, in
|
|
5
|
+
[`adr/`](adr/); this page is the map and the tour.
|
|
6
|
+
|
|
7
|
+
The plan that will split the repository into packages is
|
|
8
|
+
[Modularization plan](../development/modularization-plan.md). That plan is a
|
|
9
|
+
roadmap; this page describes the code as it is.
|
|
10
|
+
|
|
11
|
+
## Module map
|
|
12
|
+
|
|
13
|
+
| Path | Role |
|
|
14
|
+
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
15
|
+
| `src/bin/index.ts` | `DM`: the Express app, plugin loading, the authentication dispatcher, `ready` and `stop`, `withConfig` |
|
|
16
|
+
| `src/config/` | Command-line and environment parsing (`args.ts`), option specs (`options.ts`), the entity-schema model (`schema.ts`; the directory's own schema is `src/lib/ldap/schema.ts`) |
|
|
17
|
+
| `src/abstract/` | `DmPlugin` base class; `LdapFlat`, the entity CRUD built on it; `TwakePlugin` |
|
|
18
|
+
| `src/hooks.ts` | The hook type map: one entry per hook, with its arguments and its answer |
|
|
19
|
+
| `src/lib/` | Core services: `ldapActions` and `lib/ldap/` (pool, search cache, schema), `lib/auth/`, `lib/authz/`, `lib/services/`, `lib/storage/`, `hookBus`, `requestContext`, errors, routes |
|
|
20
|
+
| `src/plugins/auth/` | Authentication plugins, authorization plugins, and the guards that sit before them |
|
|
21
|
+
| `src/plugins/ldap/` | Directory plugins: groups, organizations, entities, change detection, trash, password policy |
|
|
22
|
+
| `src/plugins/scim/` | The SCIM 2.0 API |
|
|
23
|
+
| `src/plugins/storage/` | Storage backends (`file`, `ldap`, `postgres`, `valkey`), each a `Store` |
|
|
24
|
+
| `src/plugins/twake/` | Twake integrations: mail, calendar, spaces, drives, tombstones, lifecycle events |
|
|
25
|
+
| `src/plugins/*.ts` | Standalone plugins: `storage` (`core/storage`, over the backends above), `static`, `weblogs`, `configApi`, `metrics`, `bcl`, and `rabbitmq` (a shared broker connection, not a store) |
|
|
26
|
+
| `src/plugins/demo/` | Example plugins |
|
|
27
|
+
| `src/browser/` | Browser libraries shipped with the package |
|
|
28
|
+
| `src/logger/` | Log formatting and levels |
|
|
29
|
+
| `src/types/` | Shared TypeScript declarations |
|
|
30
|
+
|
|
31
|
+
The plugin families depend on `src/lib/` and on each other as documented in
|
|
32
|
+
[dependencies](../plugin-development/dependencies.md). The dependency rules
|
|
33
|
+
that the modularization will enforce are in the
|
|
34
|
+
[plan](../development/modularization-plan.md#target-architecture).
|
|
35
|
+
|
|
36
|
+
## Life of a request
|
|
37
|
+
|
|
38
|
+
```mermaid
|
|
39
|
+
flowchart TD
|
|
40
|
+
A[HTTP request] --> B[request id and log context]
|
|
41
|
+
B --> C[body parsers]
|
|
42
|
+
C --> D[protect plugins: rate limit, CrowdSec, trusted proxy]
|
|
43
|
+
D --> E[authentication dispatcher]
|
|
44
|
+
E --> F{most specific prefix}
|
|
45
|
+
F -->|one authenticator| G[vouched]
|
|
46
|
+
F -->|several on the same prefix| H[all of them, an AND]
|
|
47
|
+
H --> G
|
|
48
|
+
G --> I[runForRequest]
|
|
49
|
+
I --> J[route: checked params, query, body]
|
|
50
|
+
J --> K[ldap.forRequest or ldap.system]
|
|
51
|
+
K --> L[request hooks, authorization first]
|
|
52
|
+
L --> M[(directory)]
|
|
53
|
+
M --> N[done hooks of a write, then end hooks]
|
|
54
|
+
N --> O[onLdap* notifications]
|
|
55
|
+
J --> P[error middleware]
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
1. `DM` mounts, in this order: the request id and log context
|
|
59
|
+
(`runWithLogContext`), the media-type guard, the parsers a plugin
|
|
60
|
+
registered for its own prefix, the global JSON and form parsers (they keep
|
|
61
|
+
the raw bytes: HMAC authentication hashes them as the client signed them),
|
|
62
|
+
the health probes, the protect and logging plugins, the authentication
|
|
63
|
+
dispatcher, the plugin routes, and the error middleware, kept last.
|
|
64
|
+
2. The dispatcher picks the authentication whose path prefix is the most
|
|
65
|
+
specific. Several plugins sharing that winning prefix all run, in
|
|
66
|
+
registration order, and every one must let the request through: two
|
|
67
|
+
credentials on one branch are an AND. A path nobody claims is guarded by
|
|
68
|
+
the unscoped authenticators (no prefix), if any are loaded; with none, the
|
|
69
|
+
request passes through unauthenticated, and `warnUnauthenticatedRoutes`
|
|
70
|
+
names those routes at startup.
|
|
71
|
+
3. A plugin's `afterChain` runs after the whole selected chain and before any
|
|
72
|
+
route: it is the only place where a verdict can depend on several
|
|
73
|
+
authenticators without registration order deciding the answer.
|
|
74
|
+
4. `runForRequest(req, next)` wraps everything downstream of a request an
|
|
75
|
+
authenticator let through — not the pass-through path, which has no
|
|
76
|
+
caller. A directory operation made there, by whatever plugin, is judged as
|
|
77
|
+
that caller even if the request object was not handed down. Hooks are the
|
|
78
|
+
exception: they run as the server (see below), and a hook acting as the
|
|
79
|
+
caller names the request it received. `ldap.system` is the explicit escape
|
|
80
|
+
anywhere else.
|
|
81
|
+
5. A route declares its input schemas with `defineRoute`; the handler receives
|
|
82
|
+
checked `params`, `query` and `body`, and reaches the directory through
|
|
83
|
+
`ldap.forRequest(req)`. Work the server does for itself goes through
|
|
84
|
+
`ldap.system`.
|
|
85
|
+
6. `ldapActions` raises the request hooks (authorization plugins first), makes
|
|
86
|
+
the operation, then raises the "done" and "end" hooks of a write. The next
|
|
87
|
+
section follows one.
|
|
88
|
+
|
|
89
|
+
The decisions behind these steps: [authentication
|
|
90
|
+
dispatch](adr/0002-authentication-dispatch.md), [authorization
|
|
91
|
+
composition](adr/0003-authorization-composition.md), [fail-closed request
|
|
92
|
+
context](adr/0004-fail-closed-request-context.md).
|
|
93
|
+
|
|
94
|
+
## Life of a write
|
|
95
|
+
|
|
96
|
+
```mermaid
|
|
97
|
+
sequenceDiagram
|
|
98
|
+
participant R as route
|
|
99
|
+
participant L as ldapActions
|
|
100
|
+
participant H as plugins
|
|
101
|
+
participant D as directory
|
|
102
|
+
|
|
103
|
+
R->>L: add / modify / rename / delete
|
|
104
|
+
L->>H: request hook, chained (authorization first)
|
|
105
|
+
H-->>L: value, changed or refused
|
|
106
|
+
L->>L: drop cached reads the write changes
|
|
107
|
+
L->>D: LDAP add / modify / modifyDN / delete
|
|
108
|
+
D-->>L: result
|
|
109
|
+
L->>L: drop them again
|
|
110
|
+
L->>H: done hooks, notified
|
|
111
|
+
L->>H: end hooks, once the done hooks returned
|
|
112
|
+
H->>H: onLdapEntryChange, onLdapChange, onLdapMailChange…
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
- **Before the write.** A request hook is chained: each subscriber gets what
|
|
116
|
+
the previous one answered, and a refusal thrown by any of them stops the
|
|
117
|
+
operation. The request hooks of authorization plugins run before every
|
|
118
|
+
other subscriber's, so a caller who may not make the operation is refused
|
|
119
|
+
before a plugin judges its content.
|
|
120
|
+
[trash](adr/0007-trash-and-tombstone.md) and
|
|
121
|
+
[tombstone](adr/0007-trash-and-tombstone.md) use this point to replace a
|
|
122
|
+
delete.
|
|
123
|
+
- **The write.** `ldapActions` drops the cached reads of what it is about to
|
|
124
|
+
change before the directory call and again once it returned, so a read
|
|
125
|
+
landing during the round trip cannot serve what the write replaced.
|
|
126
|
+
[Search cache](adr/0006-search-cache.md).
|
|
127
|
+
- **After the write.** `ldapadddone`, `ldapmodifydone`, `ldapdeletedone` and
|
|
128
|
+
`ldaprenamedone` are notified, not awaited. `ldapaddafter` is awaited
|
|
129
|
+
within the request, for what must follow an add, such as provisioning the
|
|
130
|
+
account the entry describes. Once the done subscribers returned, the
|
|
131
|
+
matching `ldapmodifyend`, `ldapdeleteend` or `ldaprenameend` fires, whether
|
|
132
|
+
the write landed, was refused or was taken out of the request.
|
|
133
|
+
- **Derived notifications.** `core/ldap/onChange` reads the entry before and
|
|
134
|
+
after the write and raises `onLdapEntryChange`, then the attribute-specific
|
|
135
|
+
ones (`onLdapMailChange`, `onLdapQuotaChange`, …). A delete fires
|
|
136
|
+
`onLdapEntryChange`, `onLdapChange` and `onLdapMailChange` only.
|
|
137
|
+
- **Contract.** [Hook contract](adr/0005-hook-contract.md), and the reference
|
|
138
|
+
of every hook in [hooks](../plugin-development/hooks.md). Subscribers run as
|
|
139
|
+
the server, outside the request that raised the hook; one that acts as the
|
|
140
|
+
caller names the request in its arguments.
|
|
141
|
+
|
|
142
|
+
## Startup
|
|
143
|
+
|
|
144
|
+
The configuration is parsed from the command line and `DM_*` variables, then
|
|
145
|
+
`DM` loads the plugins: `src/plugins/priority.json` first, one at a time in
|
|
146
|
+
its order, then the rest in parallel. Once every load settled, the server
|
|
147
|
+
checks the authorization composition, learns the directory's attribute types
|
|
148
|
+
when an authorization plugin is loaded, reports unknown options and runs
|
|
149
|
+
`afterLoad`. `DM.ready` resolves then, and rejects if a plugin failed to load;
|
|
150
|
+
the CLI opens the port after it. An embedding host may register plugins after
|
|
151
|
+
`ready`, and each registration is checked against the composition too.
|
|
152
|
+
`warnUnauthenticatedRoutes` stays silent when no authentication plugin is
|
|
153
|
+
loaded; the enforcement check says once whether that is an open server or a
|
|
154
|
+
configuration to refuse.
|
|
155
|
+
|
|
156
|
+
[Plugin load order](adr/0001-plugin-load-order.md),
|
|
157
|
+
[server views](adr/0008-withconfig-views.md).
|
|
158
|
+
|
|
159
|
+
## Decision records
|
|
160
|
+
|
|
161
|
+
| Record | Decision |
|
|
162
|
+
| ----------------------------------------------- | --------------------------------------------------------------------------------------- |
|
|
163
|
+
| [0001](adr/0001-plugin-load-order.md) | Plugins load priority-first, then in parallel; dependencies load on demand |
|
|
164
|
+
| [0002](adr/0002-authentication-dispatch.md) | The most specific authentication prefix wins; several on one prefix are an AND |
|
|
165
|
+
| [0003](adr/0003-authorization-composition.md) | Two authorization models judging the same requests are refused unless made deliberate |
|
|
166
|
+
| [0004](adr/0004-fail-closed-request-context.md) | Directory operations inherit the request being served; `ldap.system` is the escape |
|
|
167
|
+
| [0005](adr/0005-hook-contract.md) | Chained hooks transform and refuse; notified hooks observe, outside the request |
|
|
168
|
+
| [0006](adr/0006-search-cache.md) | The search cache is off by default and only the process's own writes invalidate it |
|
|
169
|
+
| [0007](adr/0007-trash-and-tombstone.md) | A delete becomes a move to trash or a tombstone, never a loss |
|
|
170
|
+
| [0008](adr/0008-withconfig-views.md) | A plugin with overrides sees a server view that shares everything but the configuration |
|
|
171
|
+
|
|
172
|
+
## Checking the comments
|
|
173
|
+
|
|
174
|
+
`npm run check:comments` (`scripts/check-comments.sh`) refuses new comments in
|
|
175
|
+
`src/` that cite an issue number, narrate history ("used to", "since 1.2") or
|
|
176
|
+
otherwise carry rationale. The rationale belongs in a decision record; the
|
|
177
|
+
comment points at it. The accepted set is `scripts/comment-baseline.txt`, and
|
|
178
|
+
it may only shrink: removing a comment is fine, adding one fails the check.
|
|
179
|
+
`sh scripts/check-comments.sh --update` rewrites the baseline from the current
|
|
180
|
+
tree when a change removes one.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# 0001 — Plugin load order
|
|
2
|
+
|
|
3
|
+
## Context
|
|
4
|
+
|
|
5
|
+
Plugins are listed with `--plugin`, in the order the administrator writes
|
|
6
|
+
them, and a plugin may declare `dependencies` naming modules that must be
|
|
7
|
+
registered before it. Some plugins cannot simply be imported in parallel with
|
|
8
|
+
the rest: a guard whose Express middleware must sit ahead of the routes it
|
|
9
|
+
protects, an access log that must see every request, an authenticator or URL
|
|
10
|
+
gate other plugins must find registered when they register. Import order in
|
|
11
|
+
the parallel batch is the order the imports happen to complete, which is not a
|
|
12
|
+
configuration.
|
|
13
|
+
|
|
14
|
+
## Decision
|
|
15
|
+
|
|
16
|
+
`src/plugins/priority.json` lists the modules loaded first. That pass is
|
|
17
|
+
sequential, in the file's order, and every instance of a priority module is
|
|
18
|
+
loaded there, in configuration order — matching is on the module, not on the
|
|
19
|
+
whole `--plugin` entry, so a named instance (`module:name:{…}`) keeps its
|
|
20
|
+
rank. The remaining entries load in parallel. A declared dependency is loaded
|
|
21
|
+
on demand when the plugin that needs it registers. A module requested twice
|
|
22
|
+
under the same name without overrides is loaded once; a second instance needs
|
|
23
|
+
its own name. Two plugins with the same instance name are refused, and the
|
|
24
|
+
second is dropped with a warning. `configApi` loads last.
|
|
25
|
+
|
|
26
|
+
The list holds four kinds of plugin: the guards whose middleware must run
|
|
27
|
+
before the routes (`trustedProxy`, `crowdsec`, `rateLimit`), the access log
|
|
28
|
+
that must see every request (`weblogs`), the authenticators the dispatcher and
|
|
29
|
+
the authorization plugins naming them have to find registered (`token`,
|
|
30
|
+
`fake`, `llng`, `openidconnect`), and the URL gate whose rules must exist
|
|
31
|
+
before the routes they judge (`authzPerRoute`). `core/storage` is there
|
|
32
|
+
because others must find it registered when they register.
|
|
33
|
+
|
|
34
|
+
Hooks run in load order, except the request hooks of authorization plugins,
|
|
35
|
+
which run before every other subscriber's.
|
|
36
|
+
|
|
37
|
+
## Consequences
|
|
38
|
+
|
|
39
|
+
- A plugin whose middleware must precede routes has to be in
|
|
40
|
+
`priority.json`; being written first in the configuration is not enough
|
|
41
|
+
once the parallel batch starts.
|
|
42
|
+
- The relative order of two plugins that are neither priority nor linked by a
|
|
43
|
+
dependency is not defined, and no plugin may rely on it.
|
|
44
|
+
- Adding a module to the priority list changes the order for every
|
|
45
|
+
deployment, so the list stays short.
|
|
46
|
+
- An instance named with overrides loads at the rank of its module but keeps
|
|
47
|
+
its own configuration through a [server view](0008-withconfig-views.md).
|
|
48
|
+
|
|
49
|
+
See [dependencies](../../plugin-development/dependencies.md) and
|
|
50
|
+
`src/bin/index.ts`.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# 0002 — Authentication dispatch
|
|
2
|
+
|
|
3
|
+
## Context
|
|
4
|
+
|
|
5
|
+
One server can host populations that authenticate differently: administrators
|
|
6
|
+
behind a token, users behind OpenID Connect, machine clients behind HMAC. An
|
|
7
|
+
authentication plugin declares the path prefixes it claims. A request must run
|
|
8
|
+
the authentication that owns its path, and only that one: running two
|
|
9
|
+
unrelated authenticators on one request composes as an AND that no credential
|
|
10
|
+
can satisfy. A request no plugin claims must not silently become open without
|
|
11
|
+
saying so.
|
|
12
|
+
|
|
13
|
+
## Decision
|
|
14
|
+
|
|
15
|
+
`DM` mounts one dispatcher layer, once, at the boundary between the protection
|
|
16
|
+
plugins and the routes. For each request it selects the plugins whose prefix
|
|
17
|
+
is the longest match. Several plugins sharing that winning prefix all run, in
|
|
18
|
+
registration order, and every one must let the request through. When no
|
|
19
|
+
plugin claims the path, the unscoped plugins (no prefix) run; when there is
|
|
20
|
+
none, the request passes through unauthenticated, outside any request
|
|
21
|
+
context. A plugin's `afterChain` runs after the whole chain and before the
|
|
22
|
+
route. `runForRequest` then wraps everything downstream of a request an
|
|
23
|
+
authenticator let through, and the dispatcher records which plugins vouched
|
|
24
|
+
for the request in `req.authenticators`.
|
|
25
|
+
|
|
26
|
+
`warnUnauthenticatedRoutes` lists, at startup, the routes that no
|
|
27
|
+
authentication plugin covers. An unscoped authenticator guards everything and
|
|
28
|
+
silences the warning.
|
|
29
|
+
|
|
30
|
+
## Consequences
|
|
31
|
+
|
|
32
|
+
- Two authenticators on one prefix require both credentials. That is
|
|
33
|
+
legitimate — a session and a token — but it fails closed when accidental.
|
|
34
|
+
- Scoping authentication to a prefix opens every route outside it; the
|
|
35
|
+
startup warning is the only signal, and it names the routes.
|
|
36
|
+
- `afterChain` is the only hook that sees all the selected authenticators'
|
|
37
|
+
verdicts, so an answer that depends on several of them belongs there, not
|
|
38
|
+
in a plugin's own middleware.
|
|
39
|
+
- An authorization plugin can tell whose request it judges from
|
|
40
|
+
`req.authenticators`; see
|
|
41
|
+
[authorization composition](0003-authorization-composition.md).
|
|
42
|
+
|
|
43
|
+
See `src/bin/index.ts` (`dispatchAuth`) and `src/lib/auth/base.ts`.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# 0003 — Authorization composition
|
|
2
|
+
|
|
3
|
+
## Context
|
|
4
|
+
|
|
5
|
+
Every plugin that judges directory operations registers the same request
|
|
6
|
+
hooks (`ldapsearchrequest`, `ldapaddrequest`, `ldapmodifyrequest`,
|
|
7
|
+
`ldapdeleterequest`, `ldaprenamerequest`). Chained hooks run their
|
|
8
|
+
subscribers in order and stop at the first refusal, so two loaded
|
|
9
|
+
authorization plugins compose as an AND: the first to refuse answers. That
|
|
10
|
+
rule is sound when it is meant. It is a refusal nobody can explain when it is
|
|
11
|
+
not: `authzPerBranch` next to `authzDynamic` judges a machine token by a
|
|
12
|
+
branch configuration written for administrators and refuses a write the
|
|
13
|
+
token's ACLs grant.
|
|
14
|
+
|
|
15
|
+
## Decision
|
|
16
|
+
|
|
17
|
+
A plugin with the `authz` role that registers LDAP hooks is a _judge_. It
|
|
18
|
+
judges the requests authenticated by the plugins named in `--authz-for`, or
|
|
19
|
+
everyone by default. A plugin that authenticates as well judges only its own
|
|
20
|
+
requests: its hooks read the credential it verified.
|
|
21
|
+
|
|
22
|
+
`assertAuthzComposition` runs once every configured plugin is loaded, and
|
|
23
|
+
again for each plugin registered later. It refuses:
|
|
24
|
+
|
|
25
|
+
- an `authz_for` naming a plugin that is not a loaded authenticator — the
|
|
26
|
+
plugin would judge nobody. A server-wide `--authz-for` that no loaded plugin
|
|
27
|
+
reads only warns, as does an `authz_for` on a plugin that also
|
|
28
|
+
authenticates;
|
|
29
|
+
- two judges whose populations overlap on an authenticator — the same one, or
|
|
30
|
+
one of them judging everyone — unless `--authz-combine` makes the AND
|
|
31
|
+
deliberate.
|
|
32
|
+
|
|
33
|
+
Two judges whose authenticators can run on the same prefix, without sharing an
|
|
34
|
+
authenticator name, are reported, not refused: asking for two credentials is
|
|
35
|
+
already a decision, and a request carrying both is judged by both. A judge on
|
|
36
|
+
`authzPerRoute` gates URLs and registers no LDAP hook, so it is not part of
|
|
37
|
+
this AND — route plus branch is a documented combination.
|
|
38
|
+
|
|
39
|
+
The request hooks of judges run before every other plugin's request hook and
|
|
40
|
+
`ldapsearchfilter` keeps the load order. All the LDAP hooks compose as an
|
|
41
|
+
AND, including the filters: two filters narrow the result twice.
|
|
42
|
+
|
|
43
|
+
## Consequences
|
|
44
|
+
|
|
45
|
+
- A deployment with two branch models on the same population no longer starts
|
|
46
|
+
unless it says `--authz-combine`; the error names both plugins.
|
|
47
|
+
- Naming authenticators in `--authz-for` requires stable instance names, so
|
|
48
|
+
the configuration that scopes authentication and the one that scopes
|
|
49
|
+
authorization must agree.
|
|
50
|
+
- A plugin registered by an embedding host is checked before its routes and
|
|
51
|
+
hooks exist, so a refused plugin leaves nothing behind.
|
|
52
|
+
- Judges run first, in their load order. The chain stops at the first refusal,
|
|
53
|
+
so a judge after a refusing one does not run; the refusal is the answer.
|
|
54
|
+
No judge is skipped while the request is still allowed.
|
|
55
|
+
- A judge that also authenticates judges only its own requests; a request it
|
|
56
|
+
did not vouch for passes its hooks untouched, because the absence of its
|
|
57
|
+
credential is not a refusal it makes.
|
|
58
|
+
|
|
59
|
+
See `src/lib/authz/composition.ts` and `src/config/args.ts`.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# 0004 — Fail-closed request context
|
|
2
|
+
|
|
3
|
+
## Context
|
|
4
|
+
|
|
5
|
+
An authorization judge decides from the request (`req.user`, its
|
|
6
|
+
authenticators, its path). But a directory operation is often made deep in a
|
|
7
|
+
plugin that never received the request: a provisioning step incremented from a
|
|
8
|
+
hook, a consistency repair, a route helper that searches before it writes.
|
|
9
|
+
Passing the request down every call is easy to forget, and a forgotten request
|
|
10
|
+
used to mean an operation nobody judged — a hole that is invisible until
|
|
11
|
+
someone writes through it.
|
|
12
|
+
|
|
13
|
+
## Decision
|
|
14
|
+
|
|
15
|
+
The authentication dispatcher runs everything downstream of an authenticated
|
|
16
|
+
request inside `runForRequest(req, next)`, an `AsyncLocalStorage`. Every
|
|
17
|
+
directory operation defaults to `currentRequest()`, so a plugin that forgets
|
|
18
|
+
the request still gets the caller's identity and is judged as them. Operations
|
|
19
|
+
the server makes for itself run inside `outsideRequest()`, which `ldap.system`
|
|
20
|
+
uses: it is the explicit escape from judgement, not a default.
|
|
21
|
+
|
|
22
|
+
The log context is a second `AsyncLocalStorage`, kept apart: a line written as
|
|
23
|
+
the server, outside the request, still names the request that made the server
|
|
24
|
+
act. `outsideAnyRequest()` leaves both, for work that outlives a request (a
|
|
25
|
+
pooled connection, a process-wide client).
|
|
26
|
+
|
|
27
|
+
Hooks are the one place that deliberately leaves the context: the bus runs
|
|
28
|
+
every subscriber inside `outsideRequest()`, so a hook's own writes are the
|
|
29
|
+
server's and not the caller's. A hook that judges or acts for the caller has
|
|
30
|
+
the request in its arguments and names it.
|
|
31
|
+
|
|
32
|
+
## Consequences
|
|
33
|
+
|
|
34
|
+
- A plugin that means to act as the server must say so with `ldap.system`;
|
|
35
|
+
forgetting the request fails closed instead of opening a hole.
|
|
36
|
+
- A test that calls a plugin's directory operations directly must wrap them in
|
|
37
|
+
`runForRequest` to reproduce what the server does, or get the unjudged
|
|
38
|
+
behaviour of `ldap.system`.
|
|
39
|
+
- Asynchronous work started inside the request inherits it, so a background
|
|
40
|
+
task that should not act as the caller has to leave the context first.
|
|
41
|
+
- Errors and logging inherit the request id, which makes a write traceable to
|
|
42
|
+
the request that caused it.
|
|
43
|
+
|
|
44
|
+
See `src/lib/requestContext.ts` and `src/lib/ldapActions.ts`
|
|
45
|
+
(`forRequest`, `system`, `currentRequest`).
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# 0005 — Hook contract
|
|
2
|
+
|
|
3
|
+
## Context
|
|
4
|
+
|
|
5
|
+
Plugins have to talk to each other without importing each other. A refusal
|
|
6
|
+
has to be able to stop a write; a notification has to be able to run after the
|
|
7
|
+
write, without holding the request open or turning a subscriber's failure into
|
|
8
|
+
the caller's failure. One kind of hook cannot do both.
|
|
9
|
+
|
|
10
|
+
## Decision
|
|
11
|
+
|
|
12
|
+
There are two kinds, told apart by what a subscriber answers, and a third
|
|
13
|
+
notification mode:
|
|
14
|
+
|
|
15
|
+
- **Chained** (`chain(name, value)`). The value goes from one subscriber to
|
|
16
|
+
the next; the last answer is the result. A subscriber that throws stops the
|
|
17
|
+
chain and the error travels to the caller: a refusal is a refusal. Several
|
|
18
|
+
arguments are passed as one tuple.
|
|
19
|
+
- **Notified** (`notify(name, ...args)`). Subscribers run one after the other,
|
|
20
|
+
outside the request that caused the write, and are not awaited. A failing
|
|
21
|
+
subscriber is logged with its plugin and hook, counted in the hook failure
|
|
22
|
+
metrics, and the next one still runs.
|
|
23
|
+
- **Awaited notification** (`notifyAndWait`). As `notify`, but the caller waits
|
|
24
|
+
for the subscribers. A failure is still logged and swallowed.
|
|
25
|
+
- **Strict notification** (`notifyStrict`) for the rare failure that is part
|
|
26
|
+
of the answer. Every subscriber runs; the first failure is raised once they
|
|
27
|
+
all returned.
|
|
28
|
+
|
|
29
|
+
Every hook is declared in `src/hooks.ts` with its arguments and its answer. A
|
|
30
|
+
plugin subscribing to an undeclared hook does not compile, and `npm run
|
|
31
|
+
check:hooks` keeps the documentation in step. Notified hooks take their
|
|
32
|
+
arguments one by one; chained hooks take one value, a tuple when there are
|
|
33
|
+
several.
|
|
34
|
+
|
|
35
|
+
The request hooks of authorization plugins run first, in load order. The
|
|
36
|
+
other hooks run in plugin load order.
|
|
37
|
+
|
|
38
|
+
## Consequences
|
|
39
|
+
|
|
40
|
+
- A notified hook cannot refuse a write; a plugin that needs to refuse
|
|
41
|
+
subscribes to the request hook instead.
|
|
42
|
+
- A chained subscriber must answer the value it was given, changed or not: an
|
|
43
|
+
`undefined` answer becomes the next subscriber's input.
|
|
44
|
+
- A subscriber of a notified hook written `([dn, entry], ctx) => …` compiles
|
|
45
|
+
without annotation — its parameter takes the declared type, a `string`, and
|
|
46
|
+
a string can be destructured — and then reads the first character of the
|
|
47
|
+
DN. The documentation repeats the shape of each hook for this reason.
|
|
48
|
+
- A "done" hook fires once the operation is over, including a modify that had
|
|
49
|
+
nothing to apply; a write refused by a request hook or by the directory
|
|
50
|
+
never reaches it. The "end" hook fires all the same, once the done
|
|
51
|
+
subscribers returned. Keying state by the operation number is what tells
|
|
52
|
+
two writes of one DN apart.
|
|
53
|
+
|
|
54
|
+
See [hooks](../../plugin-development/hooks.md) and `src/lib/hookBus.ts`.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# 0006 — Search cache
|
|
2
|
+
|
|
3
|
+
## Context
|
|
4
|
+
|
|
5
|
+
Reading one entry by DN is the most frequent operation a route makes: before a
|
|
6
|
+
write, before an authorization decision, to render a page. A directory round
|
|
7
|
+
trip per read is measurable, and a cache in front of it is easy to add and
|
|
8
|
+
hard to keep honest. Serving a stale entry after a write is worse than the
|
|
9
|
+
round trip.
|
|
10
|
+
|
|
11
|
+
## Decision
|
|
12
|
+
|
|
13
|
+
`SearchCache` caches non-paged, base-scope searches — single-entry lookups by
|
|
14
|
+
DN, with whatever filter — keyed by base, scope, filter, attributes, options
|
|
15
|
+
and values mode, in an LRU bounded by `--ldap-cache-max`. It is **off by
|
|
16
|
+
default** (`--ldap-cache-ttl=0`): no deployment has run with it on, and
|
|
17
|
+
turning it on for everyone would be a silent behaviour change.
|
|
18
|
+
|
|
19
|
+
`ldapActions` drops, around every write it makes through this process:
|
|
20
|
+
|
|
21
|
+
- the reads of the entry written and of its subtree (a rename or a move
|
|
22
|
+
re-parents a whole branch);
|
|
23
|
+
- the `memberOf` of the members a group change adds or removes;
|
|
24
|
+
- the reads of entries naming the entry renamed or deleted, because the
|
|
25
|
+
directory's `memberof` and `refint` overlays rewrite them.
|
|
26
|
+
|
|
27
|
+
Entries are dropped before the directory call as well as after it, and a
|
|
28
|
+
generation counter stops a read that started before the write from storing
|
|
29
|
+
its stale answer afterwards. DNs are compared the way the directory does:
|
|
30
|
+
case, spacing, escapes, aliases and multi-valued RDN order do not matter.
|
|
31
|
+
|
|
32
|
+
Writes made elsewhere — another replica, an LSC synchronisation, a hand-run
|
|
33
|
+
`ldapmodify` — do not invalidate anything. Each process caches on its own.
|
|
34
|
+
|
|
35
|
+
## Consequences
|
|
36
|
+
|
|
37
|
+
- Enable the cache only when this service is the only writer, or when serving
|
|
38
|
+
an entry up to `--ldap-cache-ttl` seconds old is acceptable.
|
|
39
|
+
- A subtree or one-level read, or a paged search, is never cached; the cache
|
|
40
|
+
does not accelerate listings. A base-scope read with a filter is cached,
|
|
41
|
+
keyed by that filter.
|
|
42
|
+
- The cache does not lift a miss: a read of an absent DN throws and caches
|
|
43
|
+
nothing.
|
|
44
|
+
- The invalidation rules follow the overlays this project documents
|
|
45
|
+
(`memberof`, `refint`); a deployment with an overlay that rewrites other
|
|
46
|
+
entries must accept a miss or leave the cache off.
|
|
47
|
+
|
|
48
|
+
See [configuration](../../usage/configuration.md#the-search-cache) and
|
|
49
|
+
`src/lib/ldap/searchCache.ts`.
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# 0007 — Trash and tombstone
|
|
2
|
+
|
|
3
|
+
## Context
|
|
4
|
+
|
|
5
|
+
Deleting an entry destroys the identifiers it held: a mail address, a login, a
|
|
6
|
+
DN. An administrator who deletes the wrong account cannot undo it, and a
|
|
7
|
+
deleted account's address can be taken by a new one immediately — which, for a
|
|
8
|
+
mail system, means the new account receives the old one's mail. The directory
|
|
9
|
+
has no undelete of its own.
|
|
10
|
+
|
|
11
|
+
## Decision
|
|
12
|
+
|
|
13
|
+
Two plugins answer a delete at the `ldapdeleterequest` hook, and a deployment
|
|
14
|
+
picks one per branch:
|
|
15
|
+
|
|
16
|
+
- **`core/ldap/trash`** moves the entry, by an atomic `modifyDN`, to a trash
|
|
17
|
+
branch under `--trash-base`, overwriting an earlier entry with the same RDN
|
|
18
|
+
there; unless `--trash-add-metadata=false`, it then replaces `description`
|
|
19
|
+
with the deletion date and the original DN, a failure there being logged
|
|
20
|
+
and not fatal. The native delete is cancelled. `--trash-watched-bases`
|
|
21
|
+
selects the branches; an empty list watches every branch.
|
|
22
|
+
- **`core/twake/tombstone`** keeps the entry in place: one modify sets the
|
|
23
|
+
deleted attribute and the lock, the deletion date and the reason when their
|
|
24
|
+
attributes are configured, and removes the attributes the deployment names.
|
|
25
|
+
The identifier is reserved until an operator erases the tombstone, once the
|
|
26
|
+
deletion is old enough or immediately when forced. Erasing removes the entry
|
|
27
|
+
and its group memberships and announces nothing.
|
|
28
|
+
|
|
29
|
+
The tombstone moves itself last in the `ldapdeleterequest` and
|
|
30
|
+
`ldapaddrequest` chains (`hookBus.toLast`), so every plugin that may refuse
|
|
31
|
+
has judged. The trash does not: it runs in load order, after the authorization
|
|
32
|
+
judges but possibly before another plugin that can refuse a delete
|
|
33
|
+
(`organizations`, `enterpriseRules`, `james`). A plugin that refuses a delete
|
|
34
|
+
and loads after the trash refuses one that has already been moved. The
|
|
35
|
+
tombstone is written by the server, not as the caller: the caller's delete
|
|
36
|
+
right is what was judged.
|
|
37
|
+
|
|
38
|
+
SCIM treats a tombstone as gone: its reads carry a not-deleted filter, so
|
|
39
|
+
reads answer 404 and lists omit it; a create with the same identity erases
|
|
40
|
+
the tombstone and creates a new entry. A non-SCIM delete of an existing
|
|
41
|
+
tombstone writes nothing and fires `twakedeletionreplay`, so lifecycle events
|
|
42
|
+
can replay a lost deletion. A SCIM DELETE never gets there: its existence
|
|
43
|
+
check hides the tombstone and answers 404.
|
|
44
|
+
|
|
45
|
+
The server refuses to start when the tombstone is loaded beside the trash and
|
|
46
|
+
any watched branch overlaps a `--twake-tombstone-dn` pattern: the trash would
|
|
47
|
+
move the entry before the tombstone saw the delete, and no tombstone would be
|
|
48
|
+
written. An empty `--trash-watched-bases` watches every branch, so it overlaps
|
|
49
|
+
any pattern and refuses too.
|
|
50
|
+
|
|
51
|
+
## Consequences
|
|
52
|
+
|
|
53
|
+
- A delete is no longer a delete. Code that lists a branch sees what the
|
|
54
|
+
trash or the tombstone left there, filtered by the plugin that owns the
|
|
55
|
+
branch.
|
|
56
|
+
- Trash and tombstone cannot watch the same branch; the startup check names
|
|
57
|
+
the overlap.
|
|
58
|
+
- A deployment that loads the trash must load it before the plugins that can
|
|
59
|
+
refuse a delete, or accept that a refusal can come after the entry moved.
|
|
60
|
+
- A tombstone cannot be restored: clearing its deleted attribute by hand is
|
|
61
|
+
not a supported operation.
|
|
62
|
+
- Trash moves the entry, so its DN changes and references to it are not
|
|
63
|
+
rewritten by the move beyond what the directory's `refint` overlay does.
|
|
64
|
+
- Tombstones are erased by an explicit operator action, so a retention policy
|
|
65
|
+
has to run it.
|
|
66
|
+
|
|
67
|
+
See [tombstone](../../usage/plugins/integrations/tombstone.md), the plugin
|
|
68
|
+
list in [plugins](../../usage/plugins/README.md), and
|
|
69
|
+
`src/plugins/ldap/trash.ts`, `src/plugins/twake/tombstone.ts`.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# 0008 — Server views for plugins with overrides
|
|
2
|
+
|
|
3
|
+
## Context
|
|
4
|
+
|
|
5
|
+
The same plugin module can be loaded several times with different settings:
|
|
6
|
+
`--plugin 'core/auth/token:admins:{…}'` and
|
|
7
|
+
`--plugin 'core/auth/token:users:{…}'`. The plugin constructor receives the
|
|
8
|
+
server, and reads its options from `this.server.config` and `this.config`.
|
|
9
|
+
Those instances must see their own configuration while acting on the one
|
|
10
|
+
server: the same Express app, the same hooks, the same LDAP connection and
|
|
11
|
+
the same plugin registry. A second `DM` would be a second server.
|
|
12
|
+
|
|
13
|
+
## Decision
|
|
14
|
+
|
|
15
|
+
`DM.withConfig(config)` returns a view of the server built with
|
|
16
|
+
`Object.create(Object.getPrototypeOf(this))` and `Object.assign(…, this,
|
|
17
|
+
{ config })`. The own properties that hold objects — the app, the hooks, the
|
|
18
|
+
LDAP actions, the plugin registry, the authenticators — are shared by
|
|
19
|
+
reference. The configuration is `{ ...this.config, ...overrides }`, a shallow
|
|
20
|
+
copy: overridden top-level keys are the plugin's alone, nested objects and
|
|
21
|
+
arrays are still shared with the server. The loader constructs a plugin with
|
|
22
|
+
overrides on such a view.
|
|
23
|
+
|
|
24
|
+
A primitive field would be copied by value into every view, so state the views
|
|
25
|
+
must share lives in an object: the operation counter (`_operations`, read
|
|
26
|
+
through the `operationSequence` accessor) and the composition flag
|
|
27
|
+
(`_compositionState`), as the dispatcher's own state does (`_authState`). A
|
|
28
|
+
view created while the plugins load therefore sees the composition once the
|
|
29
|
+
server has checked it.
|
|
30
|
+
|
|
31
|
+
The copy is built on the prototype rather than by spreading `{...this}`,
|
|
32
|
+
which copies own properties and leaves the methods behind: a plugin calling
|
|
33
|
+
`this.server.anything()` would throw, and only when configured with
|
|
34
|
+
overrides.
|
|
35
|
+
|
|
36
|
+
## Consequences
|
|
37
|
+
|
|
38
|
+
- A plugin loaded with overrides cannot change the server's configuration for
|
|
39
|
+
anyone else; mutating `this.config` affects its view alone, at the top
|
|
40
|
+
level.
|
|
41
|
+
- Primitive fields are copied by value, not shared: what must be shared is
|
|
42
|
+
held in an object, as the operation counter and the composition flag are.
|
|
43
|
+
A plugin registered after `ready` through a view created while loading is
|
|
44
|
+
therefore checked against the composition like any other (before [#625] and
|
|
45
|
+
[#626], the counter and the flag were primitives, and neither held).
|
|
46
|
+
- An own property added to the server after the view was created is not
|
|
47
|
+
visible to that view. State that must be shared has to live in an object
|
|
48
|
+
that already exists when the view is made.
|
|
49
|
+
- A plugin that closes over `this.server` keeps the view for its lifetime,
|
|
50
|
+
which is the point: its options stay its own.
|
|
51
|
+
- Tests may call `withConfig` to exercise option-dependent behaviour without
|
|
52
|
+
building a second server.
|
|
53
|
+
|
|
54
|
+
See `src/bin/index.ts` (`withConfig`, `importPlugin`) and
|
|
55
|
+
[plugin options](../../plugin-development/options.md).
|
|
56
|
+
|
|
57
|
+
[#625]: https://github.com/linagora/ldap-rest/issues/625
|
|
58
|
+
[#626]: https://github.com/linagora/ldap-rest/issues/626
|
|
@@ -31,10 +31,15 @@ contract shows up as a diff instead of a surprise after the release:
|
|
|
31
31
|
- `openapi.json` is the REST API document the published reference is built
|
|
32
32
|
from. Regenerate it with `npm run generate:openapi`; `npm run check:openapi`
|
|
33
33
|
fails when it is stale.
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
34
|
+
- `etc/api/*.api.md` are the API Extractor reports of the five entry points
|
|
35
|
+
#595 covers first — `ldap-rest`, `./plugin`, `./hooks`, `./ldapactions` and
|
|
36
|
+
`./expressformatedresponses` — one file per sub-path. Regenerate them with
|
|
37
|
+
`npm run snapshot:api`; `npm run check:api`, part of `npm run check`, fails
|
|
38
|
+
when one is stale. A report lists what the entry exports and the shapes of
|
|
39
|
+
the types those exports reference, including the types the entry does not
|
|
40
|
+
export itself, so a change to `RouteDefinition` or `OptionSet` shows up even
|
|
41
|
+
though a plugin cannot name them. The 66 `./plugin-*` entries come with the
|
|
42
|
+
modularization, see [the plan](modularization-plan.md).
|
|
38
43
|
|
|
39
44
|
The snapshot records what the parser reads. A narrowed `choices` list, a
|
|
40
45
|
raised `min` or a lowered `max` is checked when a plugin reads its options,
|
|
@@ -59,8 +64,13 @@ that becomes required, a response that may now be null, changed security
|
|
|
59
64
|
requirements, a removed union variant, a value an alternative of the type
|
|
60
65
|
closes with an enum or a `const` where the type used to accept any, an
|
|
61
66
|
untyped request value that becomes a union, an `allOf` that adds a
|
|
62
|
-
constraint, or
|
|
63
|
-
|
|
67
|
+
constraint, or a removed declaration in `etc/api/` that a plugin can name —
|
|
68
|
+
the exported one, or a member of an exported namespace. Renaming a type only
|
|
69
|
+
reachable through a signature is not a break: nobody can write its name, and
|
|
70
|
+
the declarations that use it are reported as changed. A changed declaration is
|
|
71
|
+
printed for review and does not fail the job yet: telling an added optional
|
|
72
|
+
member from a narrowed union needs more than a line diff, and comes with the
|
|
73
|
+
`./plugin-*` reports. Renaming an option is a deprecation, not a removal, as
|
|
64
74
|
long as the old CLI name and environment variable stay in its `aliases` and
|
|
65
75
|
`envAliases`. Adding a union variant, a field or an optional parameter is
|
|
66
76
|
additive, and so is a request union with an alternative that still accepts
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# Modularization plan
|
|
2
|
+
|
|
3
|
+
How the remaining architecture work is sequenced: [#397] and the five
|
|
4
|
+
`code-quality` issues that depend on it or prepare it. The plan is a roadmap,
|
|
5
|
+
not a contract; update it as the work lands.
|
|
6
|
+
|
|
7
|
+
## Open issues
|
|
8
|
+
|
|
9
|
+
| Issue | Subject | Role | Size |
|
|
10
|
+
| ------ | ----------------------------------------------------------------------------------- | ----------------------------- | ------ |
|
|
11
|
+
| [#397] | Split the repository into `@ldap-rest/*` modules | Final step | Major |
|
|
12
|
+
| [#593] | Finish the plugin service migration: entity registry, call sites, family boundaries | Prerequisite of #397 | Large |
|
|
13
|
+
| [#594] | Finish the `ldapActions` split: operation pipeline and façade | Prerequisite of #397 | Medium |
|
|
14
|
+
| [#595] | Snapshot the plugin API types and gate changes | Safety net, start before #397 | Medium |
|
|
15
|
+
| [#549] | Architecture pages, decision records, comment check | Independent, start early | Medium |
|
|
16
|
+
| [#603] | Enable the type-checked ESLint rules the test block leaves out | Independent, quick win | Small |
|
|
17
|
+
|
|
18
|
+
[#397] is still in the `2.0.0` milestone, which is released. Retarget it to a
|
|
19
|
+
2.x epic or to 3.0.0 once the compatibility decision below is made. [#593] and
|
|
20
|
+
[#594] are labelled `Minor`/`backlog` but are the real prerequisites of
|
|
21
|
+
[#397]; promote them to the same milestone.
|
|
22
|
+
|
|
23
|
+
## What blocks #397 today
|
|
24
|
+
|
|
25
|
+
The core still imports plugin code:
|
|
26
|
+
|
|
27
|
+
- `src/config/args.ts` imports the option sets of every plugin family.
|
|
28
|
+
- `src/bin/index.ts` imports `src/plugins/priority.json`.
|
|
29
|
+
- `src/hooks.ts` imports `ChangesToNotify` from
|
|
30
|
+
`src/plugins/ldap/onChange.ts` and the SCIM types from
|
|
31
|
+
`src/plugins/scim/types.ts`.
|
|
32
|
+
- `src/abstract/ldapFlat.ts` reads `server.loadedPlugins` and uses
|
|
33
|
+
`requirePlugin`.
|
|
34
|
+
- `src/lib/ldapActions.ts` is 1,375 lines; `src/lib/ldap/operations.ts` does
|
|
35
|
+
not exist.
|
|
36
|
+
- The single package generates 66 `./plugin-*` exports from
|
|
37
|
+
`rollup.config.mjs`, with no npm workspaces.
|
|
38
|
+
|
|
39
|
+
Between plugin families the coupling is smaller: only the Twake plugins import
|
|
40
|
+
`ldap`/`scim` (15 imports), plus `TwakeGroups extends LdapGroups`.
|
|
41
|
+
|
|
42
|
+
## Target architecture
|
|
43
|
+
|
|
44
|
+
An npm workspaces monorepo with an umbrella package that preserves the public
|
|
45
|
+
API:
|
|
46
|
+
|
|
47
|
+
```text
|
|
48
|
+
packages/
|
|
49
|
+
core/ @ldap-rest/core
|
|
50
|
+
plugin-auth/ @ldap-rest/plugin-auth
|
|
51
|
+
plugin-ldap/ @ldap-rest/plugin-ldap
|
|
52
|
+
plugin-scim/ @ldap-rest/plugin-scim
|
|
53
|
+
plugin-storage/ @ldap-rest/plugin-storage
|
|
54
|
+
plugin-twake/ @ldap-rest/plugin-twake
|
|
55
|
+
plugin-misc/ @ldap-rest/plugin-misc
|
|
56
|
+
browser/ @ldap-rest/browser
|
|
57
|
+
schemas/ @ldap-rest/schemas
|
|
58
|
+
ldap-rest/ umbrella package (CLI and re-exports)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Rules:
|
|
62
|
+
|
|
63
|
+
- `core` depends on no family; a family depends on no other family. Cross-family
|
|
64
|
+
needs go through services (`src/lib/services`) or hooks.
|
|
65
|
+
- `TwakeGroups extends LdapGroups` is either the one documented exception or
|
|
66
|
+
the shared base moves to `core`.
|
|
67
|
+
- `ldap-rest` stays the entry package: it depends on every package, keeps the
|
|
68
|
+
CLI and the `core/…` namespace, and re-exports the former sub-paths
|
|
69
|
+
(`ldap-rest/plugin-auth-authzlinid1`, …). The split is therefore not breaking
|
|
70
|
+
for users.
|
|
71
|
+
- The core parser accepts an injected `OptionSet`; the umbrella composes the
|
|
72
|
+
option sets of every family. `Config` may stay a type-only aggregate in
|
|
73
|
+
`core`, but runtime option imports leave the core.
|
|
74
|
+
- Each package builds on its own with a shared configuration; the umbrella
|
|
75
|
+
re-exports. The Twake-specific `bin/` scripts move to their package or to a
|
|
76
|
+
`@ldap-rest/cli` package.
|
|
77
|
+
|
|
78
|
+
A package per plugin (`@ldap-rest/authz-linid1`, …) is possible, but start with
|
|
79
|
+
families and extract optional plugins individually later. Otherwise the first
|
|
80
|
+
day costs 66 packages and 66 release cycles.
|
|
81
|
+
|
|
82
|
+
## Phases
|
|
83
|
+
|
|
84
|
+
### Phase 0 — Guardrails and quick wins
|
|
85
|
+
|
|
86
|
+
1. [#603]: enable the type-checked rules on `test/**/*.ts`, fix the 31
|
|
87
|
+
`no-floating-promises` and 24 `await-thenable` reports, add the block to CI.
|
|
88
|
+
2. [#549], part 1: `docs/architecture/README.md` (module map, life of a
|
|
89
|
+
request, life of a write) and the decision records for the decisions already
|
|
90
|
+
in place. Add `scripts/check-comments.sh` with a baseline that may only
|
|
91
|
+
shrink. The GitHub ruleset change can land separately.
|
|
92
|
+
3. [#595], part 1: `@microsoft/api-extractor` on the five main entry points
|
|
93
|
+
(`ldap-rest`, `./plugin`, `./hooks`, `./ldapactions`,
|
|
94
|
+
`./expressformatedresponses`), reports under `etc/api/`, wired into the
|
|
95
|
+
`contracts` job. The 66 `./plugin-*` entries wait for phase 3.
|
|
96
|
+
|
|
97
|
+
Outcome: CI catches regressions, the current architecture is documented, and
|
|
98
|
+
the public types have a baseline.
|
|
99
|
+
|
|
100
|
+
### Phase 1 — Decouple the core
|
|
101
|
+
|
|
102
|
+
1. [#593]:
|
|
103
|
+
- typed entity registry replacing the six hand-written loops;
|
|
104
|
+
- `GroupsApi`, `OrganizationsApi`, `TrashApi`, `PasswordPolicyApi`,
|
|
105
|
+
`TwakeGroupsApi`, `AuthenticatorsInfo` service interfaces;
|
|
106
|
+
- replace the 68 `loadedPlugins` reads and the 17 `requirePlugin` uses;
|
|
107
|
+
- move `ChangesToNotify` out of `plugins/ldap/onChange.ts` and the SCIM
|
|
108
|
+
types out of `plugins/scim/` into `src/lib/`;
|
|
109
|
+
- ESLint: forbid `loadedPlugins` under `src/plugins/**` and imports between
|
|
110
|
+
plugin families.
|
|
111
|
+
2. [#594]: extract the operation pipeline into `src/lib/ldap/operations.ts`,
|
|
112
|
+
bring `ldapActions.ts` under 400 lines, add the file-size test, move the
|
|
113
|
+
ESLint exemption, add the type test on `ldap-rest/ldapactions`.
|
|
114
|
+
3. Options refactor (needs its own issue): split `src/config/args.ts` into core
|
|
115
|
+
options and per-family option sets; let the parser/`DM` accept an
|
|
116
|
+
`OptionSet`; move `priority.json` into the core or make it a parameter.
|
|
117
|
+
|
|
118
|
+
Outcome: `src/hooks.ts` imports no plugin, the core reads no `loadedPlugins`,
|
|
119
|
+
families do not import each other, and `ldapActions` is a stable façade.
|
|
120
|
+
|
|
121
|
+
### Phase 2 — Workspaces and extraction
|
|
122
|
+
|
|
123
|
+
1. Set up npm workspaces, `packages/`, a shared `tsconfig` and per-package
|
|
124
|
+
builds.
|
|
125
|
+
2. Extract `@ldap-rest/core` first, with the `ldap-rest` umbrella depending on
|
|
126
|
+
it and re-exporting the existing entry points.
|
|
127
|
+
3. Extract the families in dependency order: storage → auth → ldap → scim →
|
|
128
|
+
twake → misc.
|
|
129
|
+
4. Extract `browser` and `schemas`.
|
|
130
|
+
5. After each step: `npm run test:dev`, `npm run check`,
|
|
131
|
+
`npm run check:openapi`, `npm run check:options`, and the `contracts` job.
|
|
132
|
+
6. Update the Dockerfile, the `bin/` scripts, the examples and the docs.
|
|
133
|
+
|
|
134
|
+
Outcome: a modular repository, with `ldap-rest` still installable and
|
|
135
|
+
compatible.
|
|
136
|
+
|
|
137
|
+
### Phase 3 — Contracts and finalization
|
|
138
|
+
|
|
139
|
+
1. [#595], part 2: one api-extractor report per sub-path, including the
|
|
140
|
+
`./plugin-*` entries, and non-additive diffs refused by `contracts`.
|
|
141
|
+
2. [#549], part 2: the modularization decision record, a shrinking comment
|
|
142
|
+
baseline, and the `require_code_owner_review` ruleset.
|
|
143
|
+
3. Publish the scoped packages and document the deprecation policy for the
|
|
144
|
+
former sub-paths.
|
|
145
|
+
4. Close [#397].
|
|
146
|
+
|
|
147
|
+
## Branch strategy
|
|
148
|
+
|
|
149
|
+
Phase 0 lands on `master`: it is non-breaking, and #603 fixes test bugs that
|
|
150
|
+
matter to the default branch too. Merge `master` into `v3.0` before phase 1,
|
|
151
|
+
so the 3.0 work starts from a green, guarded base. Phases 1 to 3 land on
|
|
152
|
+
`v3.0`.
|
|
153
|
+
|
|
154
|
+
## Order
|
|
155
|
+
|
|
156
|
+
```text
|
|
157
|
+
#603 ───────────────┐
|
|
158
|
+
#549 (part 1) ──────┼──> Phase 0
|
|
159
|
+
#595 (part 1) ──────┘
|
|
160
|
+
│
|
|
161
|
+
#593 ────────────────┼──> Phase 1 ──> #397 (Phase 2) ──> #595/#549 (part 2)
|
|
162
|
+
#594 ────────────────┘
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
[#603] and [#549] part 1 can start in parallel. [#595] part 1 is a short
|
|
166
|
+
spike. [#593] and [#594] can then run in parallel, but both touch the core:
|
|
167
|
+
freeze new cross-family imports and new `loadedPlugins` reads from the start of
|
|
168
|
+
phase 1.
|
|
169
|
+
|
|
170
|
+
## Decisions to make
|
|
171
|
+
|
|
172
|
+
1. **Granularity**: packages per family (recommended) or one package per plugin
|
|
173
|
+
from the start?
|
|
174
|
+
2. **Versioning**: a non-breaking internal split in 2.x with the umbrella
|
|
175
|
+
(recommended), or 3.0.0 with the former sub-paths removed?
|
|
176
|
+
3. **`TwakeGroups extends LdapGroups`**: documented exception, or move the
|
|
177
|
+
shared base into `core`?
|
|
178
|
+
4. **`Config` type**: type-only aggregate in `core` (recommended to start), or
|
|
179
|
+
split per package with declaration merging?
|
|
180
|
+
5. **Build**: Rollup per package (consistent with today) or `tsc` with
|
|
181
|
+
generated exports?
|
|
182
|
+
|
|
183
|
+
## First actions
|
|
184
|
+
|
|
185
|
+
1. Retarget [#397] and add the package map and the dependency rule to it.
|
|
186
|
+
2. Open sub-issues for [#593] and [#594]; both are too large for one pull
|
|
187
|
+
request.
|
|
188
|
+
3. Start [#603] and [#549] part 1 in parallel.
|
|
189
|
+
4. Spike [#595] on the five main entry points.
|
|
190
|
+
5. Then [#593]/[#594], then the package extraction.
|
|
191
|
+
|
|
192
|
+
[#397]: https://github.com/linagora/ldap-rest/issues/397
|
|
193
|
+
[#549]: https://github.com/linagora/ldap-rest/issues/549
|
|
194
|
+
[#593]: https://github.com/linagora/ldap-rest/issues/593
|
|
195
|
+
[#594]: https://github.com/linagora/ldap-rest/issues/594
|
|
196
|
+
[#595]: https://github.com/linagora/ldap-rest/issues/595
|
|
197
|
+
[#603]: https://github.com/linagora/ldap-rest/issues/603
|
|
@@ -24,7 +24,8 @@ subscriber answers:
|
|
|
24
24
|
A chained hook is handed its arguments as one value, a tuple when it has
|
|
25
25
|
several. A notified hook is handed them one by one: its subscriber is
|
|
26
26
|
`(dn, entry, context) => …`, never `([dn, entry], context) => …` — that form
|
|
27
|
-
still compiles
|
|
27
|
+
still compiles without annotation, since its parameter takes the declared
|
|
28
|
+
`string` and a string can be destructured, but then reads the DN's first
|
|
28
29
|
character as the DN.
|
|
29
30
|
|
|
30
31
|
- **Chained.** The value goes from one subscriber to the next, each getting
|
package/docs/usage/operations.md
CHANGED
|
@@ -55,9 +55,12 @@ changes nothing.
|
|
|
55
55
|
|
|
56
56
|
The image is built in two stages: a builder produces the package and installs
|
|
57
57
|
its production dependencies, the runtime stage copies them and carries no
|
|
58
|
-
compiler nor package manager cache.
|
|
59
|
-
|
|
60
|
-
|
|
58
|
+
compiler nor package manager cache. The runtime stage upgrades the Alpine
|
|
59
|
+
packages of its base image with `apk upgrade --no-cache`, so a security fix
|
|
60
|
+
published after the base image was built still reaches the image: two builds
|
|
61
|
+
from the same digest may differ in those packages. Its base image is pinned by
|
|
62
|
+
digest in `scripts/buildDockerfile.ts`, so the image built is the one reviewed
|
|
63
|
+
even if the tag moves; the digest follows the tag it names, taken from the
|
|
61
64
|
[Docker Hub tag page](https://hub.docker.com/_/node/tags?name=25-alpine) and
|
|
62
65
|
updated by hand with it. Dependabot ignores the image: it would open a change
|
|
63
66
|
to the generated `Dockerfile`, which the drift check refuses.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ldap-rest",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.1",
|
|
4
4
|
"description": "Light directory manager",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"Identity-Manager"
|
|
@@ -806,7 +806,9 @@
|
|
|
806
806
|
"build:pages": "tsx scripts/build-pages.ts",
|
|
807
807
|
"build:prod": "npm run clean && NODE_ENV=production rollup -c && npm run build:browser && node scripts/moveBrowserLibs.mjs && npm run build:dockerfile",
|
|
808
808
|
"build:watch": "rollup -c --watch",
|
|
809
|
-
"check": "npm run check:ts && npm run lint && npm run format:check && npm run check:hooks && npm run check:no-client-values && npm run check:openapi && npm run check:options",
|
|
809
|
+
"check": "npm run check:ts && npm run lint && npm run format:check && npm run check:hooks && npm run check:comments && npm run check:no-client-values && npm run check:api && npm run check:openapi && npm run check:options",
|
|
810
|
+
"check:api": "tsx scripts/api-snapshot.ts --check",
|
|
811
|
+
"check:comments": "sh scripts/check-comments.sh",
|
|
810
812
|
"check:hooks": "tsx scripts/hooks-doc.ts",
|
|
811
813
|
"check:no-client-values": "sh scripts/check-no-client-values.sh",
|
|
812
814
|
"check:openapi": "tsx scripts/check-openapi.ts",
|
|
@@ -825,6 +827,7 @@
|
|
|
825
827
|
"generate:openapi": "tsx scripts/generate-openapi.ts",
|
|
826
828
|
"lint": "eslint \"src/**/*.{ts,tsx,js,jsx}\" \"test/**/*.ts\"",
|
|
827
829
|
"lint:fix": "eslint \"src/**/*.{ts,tsx,js,jsx}\" \"test/**/*.ts\" --fix",
|
|
830
|
+
"snapshot:api": "tsx scripts/api-snapshot.ts",
|
|
828
831
|
"snapshot:options": "tsx scripts/options-snapshot.ts",
|
|
829
832
|
"start:dev": "./.dev.mk start",
|
|
830
833
|
"test": "npm run check:ts && mocha",
|
|
@@ -853,6 +856,7 @@
|
|
|
853
856
|
},
|
|
854
857
|
"devDependencies": {
|
|
855
858
|
"@eslint/js": "^10.0.1",
|
|
859
|
+
"@microsoft/api-extractor": "^7.60.1",
|
|
856
860
|
"@rollup/plugin-commonjs": "^29.0.3",
|
|
857
861
|
"@rollup/plugin-json": "^6.1.0",
|
|
858
862
|
"@rollup/plugin-terser": "^1.0.2",
|