ldap-rest 0.24.0 → 0.25.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/bin/index.js CHANGED
@@ -1 +1 @@
1
- import{fileURLToPath as e}from"url";import{join as t,dirname as r}from"path";import i from"express";import s from"body-parser";import{parseConfig as o}from"../lib/parseConfig.js";import{keepRawBody as n}from"../lib/rawBody.js";import a from"../config/args.js";import h from"../lib/ldapActions.js";import{buildLogger as l}from"../logger/winston.js";import{setLogger as d}from"../lib/expressFormatedResponses.js";import{prefixCoversPath as u}from"../lib/auth/base.js";import{assertAuthzComposition as g}from"../lib/authz/composition.js";import{AUTHZ_REFUSED as p}from"../lib/errors.js";export{BadGatewayError,BadRequestError,ConflictError,ForbiddenError,GatewayTimeoutError,HttpError,NotFoundError,ServiceUnavailableError,TooManyRequestsError,UnauthorizedError,UriTooLongError}from"../lib/errors.js";import{recordHookOwner as c}from"../lib/utils.js";export{asyncHandler,escapeDnValue,escapeLdapFilter,escapeRegex,getCompiledRegex,getParentDn,getRdn,hookOwner,isAuthzRefusal,isChildOf,isDnInBranch,isDummyMemberDn,launchHooks,launchHooksChained,movedToTrash,normalizeDn,organizationLink,parseDn,rdnValue,substringSearchFilter,transformSchemas,unescapeDnValue,validateDnValue}from"../lib/utils.js";import f from"../plugins/priority.json.js";class DM{constructor(){this.hooks={},this.loadedPlugins={},this.authenticators=[],this._authState={dispatcherMounted:!1},this._errorMiddlewareSetup=!1,this.bodyParsers=i.Router(),this._composed=!1,this.pluginLoads=new Map,this.config=o(a),this.app=i(),this.app.use(this.bodyParsers),this.app.use(s.json({verify:n})),this.app.use(s.urlencoded({extended:!0,verify:n})),this.logger=l(this.config),this.ldap=new h(this),d(this.logger),this.operationSequence=0;const e=[];if(this.config.plugin){const t=this.config.plugin.find(e=>e.includes("configApi"));let r=this.config.plugin.filter(e=>!e.includes("configApi"));const i=this.logger,s=(async()=>{for(const e of f){const t=this.resolveModule(DM.moduleOf(e)),s=r.filter(e=>this.resolveModule(DM.moduleOf(e))===t);if(0!==s.length){r=r.filter(e=>!s.includes(e));for(const e of s)i.debug(`Loading ${e} in the priority pass`),await this.loadPlugin(e)}}})();e.push(s),e.push(s.then(async()=>{const e=r.map(e=>(i.debug(`Loading ${e} in the parallel batch`),this.loadPlugin(e)));await Promise.all(e)})),t&&e.push(Promise.all(e).then(()=>this.loadPlugin(t)))}this.ready=new Promise((t,r)=>{e.length>0?Promise.all(e).then(async()=>{this.setupErrorMiddleware(),this.warnUnauthenticatedRoutes(),await g(this),this._composed=!0,this.afterLoad(),t()}).catch(e=>r(new Error("Error loading plugins: "+e))):(this.setupErrorMiddleware(),this._composed=!0,t())})}routerStack(){const e=this.app;return(e.router??e._router)?.stack}withConfig(e){return Object.assign(Object.create(Object.getPrototypeOf(this)),this,{config:e})}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,r)=>{this.dispatchAuth(e,t,r)}))}registerAuthenticator(e){this.mountAuthDispatcherNow(),this.authenticators.includes(e)||this.authenticators.push(e)}registerBodyParser(e,t){this.bodyParsers.use(e,t)}dispatchAuth(e,t,r){let i=-1,s=[];for(const t of this.authenticators)for(const r of t.pathPrefixes)u(r,e.path)&&(r.length>i?(i=r.length,s=[t]):r.length!==i||s.includes(t)||s.push(t));if(i<0&&(s=this.authenticators.filter(e=>0===e.pathPrefixes.length)),0===s.length)return r();const finish=i=>{if(i>=s.length)return r();const o=s[i];if(!o.afterChain)return finish(i+1);o.afterChain(e,t,()=>finish(i+1))},o=[];e.authenticators=o;const run=r=>{if(r>=s.length)return finish(0);const i=s[r];i.authenticate(e,t,()=>{i.vouchedFor(e)&&o.push(i.name),run(r+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 r of e){if(!r.pathPrefixes||0===r.pathPrefixes.length)return[];t.push(...r.pathPrefixes)}const r=this.routerStack(),i=new Set;for(const e of r||[]){const t=e.route?.path;"string"==typeof t?i.add(t):Array.isArray(t)&&t.forEach(e=>"string"==typeof e&&i.add(e))}const s=[...i].filter(e=>!t.some(t=>e===t||e.startsWith(`${t}/`)));return s.length>0&&this.logger.warn(`Authentication is restricted to ${t.join(", ")}, so these routes are served without authentication: ${s.sort().join(", ")}`),s.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,r,i)=>{let s="statusCode"in e?e.statusCode:500,o=e.message;if(500===s&&/\[authz-forbidden\]/.test(e.message)?(s=403,o=p):403===s&&/\[authz-forbidden\]/.test(e.message)&&(o=p),s>=400&&s<500)return this.logger.warn(`Client error ${s} in request ${t.method} ${t.path}: ${o}`),void(r.headersSent||r.status(s).json({error:o}));this.logger.error(`Server error ${s} in request ${t.method} ${t.path}: ${e.message}`,{stack:e.stack,url:t.url,method:t.method}),r.headersSent||r.status(s).json({error:"Internal Server Error",message:this.config.debug?e.message:"An error occurred"})},this.app.use(this._errorMiddleware),this._errorMiddlewareSetup=!0}run(){return process.on("uncaughtException",e=>{this.logger.error(`Uncaught exception: ${e.message}`,{stack:e.stack})}),process.on("unhandledRejection",(e,t)=>{this.logger.error(`Unhandled promise rejection: ${e}`,{reason:e,promise:t})}),new Promise((e,t)=>{this.server=this.app.listen(this.config.port,r=>{r?(this.logger.error(`Error starting server: ${r}`),t(r)):(this.logger.debug(`Server started on port ${this.config.port}`),e())})})}stop(){this.app.removeAllListeners(),this.server?.close(),this.logger.debug("Server stopped")}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(r(e(import.meta.url)),"..","plugins")+"/").replace(/$/,".js"):i.startsWith(".")?e(new URL(i,import.meta.url)):i}loadPlugin(e){let t,r;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{r=JSON.parse(i),"object"!=typeof r?(this.logger.error(`Overrides for plugin ${e} are not valid: ${i}`),r=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}`),r=void 0}}else t=i,t||(t=void 0)}else t=void 0;const i=r?void 0:`${this.resolveModule(e)}\0${t??""}`,s=void 0===i?void 0:this.pluginLoads.get(i);if(s)return this.logger.debug(`Plugin ${e} already loaded, skipping`),s;const o=this.importPlugin(e,t,r);if(void 0!==i){this.pluginLoads.set(i,o);const forget=()=>{this.pluginLoads.get(i)===o&&this.pluginLoads.delete(i)};o.then(e=>e||forget(),forget)}return o}importPlugin(e,t,r){return this.logger.debug(`Loading plugin ${e}`),e.startsWith("core/")&&(e=this.resolveModule(e)),new Promise((i,s)=>{import(e).then(async o=>{let n;if(o&&o.default&&(o=o.default),r){const e={...this.config,...r};n=new o(this.withConfig(e))}else n=new o(this);if(!n)return s(new Error(`Unable to load ${e}`));i(await this.registerPlugin(e,n,t)),this.logger.debug(`Plugin ${n.name} loaded`)}).catch(t=>s(new Error(`Failed to load plugin ${e}: ${t}`)))})}async admitToComposition(e){this.loadedPlugins[e.name]=e;try{await g(this,e)}finally{delete this.loadedPlugins[e.name]}}async registerPlugin(e,t,r){if(t.name||(t.name=e),r&&(t.name=r),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 r=t.hooks[e];if(this.hooks[e]||(this.hooks[e]=[]),!r||"function"!=typeof r)throw new Error(`Plugin ${t.name}: hook ${e} is invalid`);this.hooks[e].push(r),c(r,t.name,e)}return this.loadedPlugins[t.name]=t,!0}}export{DM,c as recordHookOwner};
1
+ import{fileURLToPath as e}from"url";import{join as t,dirname as r}from"path";import i from"express";import s from"body-parser";import{parseConfig as o}from"../lib/parseConfig.js";import{keepRawBody as n}from"../lib/rawBody.js";import a from"../config/args.js";import h from"../lib/ldapActions.js";import{buildLogger as l}from"../logger/winston.js";import{setLogger as d}from"../lib/expressFormatedResponses.js";import{prefixCoversPath as u}from"../lib/auth/base.js";import{assertAuthzComposition as g}from"../lib/authz/composition.js";import{errorBody as p,AUTHZ_REFUSED as c}from"../lib/errors.js";export{BadGatewayError,BadRequestError,ConflictError,ForbiddenError,GatewayTimeoutError,HttpError,NotFoundError,ServiceUnavailableError,TooManyRequestsError,UnauthorizedError,UriTooLongError}from"../lib/errors.js";import{recordHookOwner as f}from"../lib/utils.js";export{asyncHandler,escapeDnValue,escapeLdapFilter,escapeRegex,getCompiledRegex,getParentDn,getRdn,hookOwner,isAuthzRefusal,isChildOf,isDnInBranch,isDummyMemberDn,launchHooks,launchHooksChained,movedToTrash,normalizeDn,organizationLink,parseDn,rdnValue,substringSearchFilter,transformSchemas,unescapeDnValue,validateDnValue}from"../lib/utils.js";import m from"../plugins/priority.json.js";class DM{constructor(){this.hooks={},this.loadedPlugins={},this.authenticators=[],this._authState={dispatcherMounted:!1},this._errorMiddlewareSetup=!1,this.bodyParsers=i.Router(),this._composed=!1,this.pluginLoads=new Map,this.config=o(a),this.app=i(),this.app.use(this.bodyParsers),this.app.use(s.json({verify:n})),this.app.use(s.urlencoded({extended:!0,verify:n})),this.logger=l(this.config),this.ldap=new h(this),d(this.logger),this.operationSequence=0;const e=[];if(this.config.plugin){const t=this.config.plugin.find(e=>e.includes("configApi"));let r=this.config.plugin.filter(e=>!e.includes("configApi"));const i=this.logger,s=(async()=>{for(const e of m){const t=this.resolveModule(DM.moduleOf(e)),s=r.filter(e=>this.resolveModule(DM.moduleOf(e))===t);if(0!==s.length){r=r.filter(e=>!s.includes(e));for(const e of s)i.debug(`Loading ${e} in the priority pass`),await this.loadPlugin(e)}}})();e.push(s),e.push(s.then(async()=>{const e=r.map(e=>(i.debug(`Loading ${e} in the parallel batch`),this.loadPlugin(e)));await Promise.all(e)})),t&&e.push(Promise.all(e).then(()=>this.loadPlugin(t)))}this.ready=new Promise((t,r)=>{e.length>0?Promise.all(e).then(async()=>{this.setupErrorMiddleware(),this.warnUnauthenticatedRoutes(),await g(this),this._composed=!0,this.afterLoad(),t()}).catch(e=>r(new Error("Error loading plugins: "+e))):(this.setupErrorMiddleware(),this._composed=!0,t())})}routerStack(){const e=this.app;return(e.router??e._router)?.stack}withConfig(e){return Object.assign(Object.create(Object.getPrototypeOf(this)),this,{config:e})}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,r)=>{this.dispatchAuth(e,t,r)}))}registerAuthenticator(e){this.mountAuthDispatcherNow(),this.authenticators.includes(e)||this.authenticators.push(e)}registerBodyParser(e,t){this.bodyParsers.use(e,t)}dispatchAuth(e,t,r){let i=-1,s=[];for(const t of this.authenticators)for(const r of t.pathPrefixes)u(r,e.path)&&(r.length>i?(i=r.length,s=[t]):r.length!==i||s.includes(t)||s.push(t));if(i<0&&(s=this.authenticators.filter(e=>0===e.pathPrefixes.length)),0===s.length)return r();const finish=i=>{if(i>=s.length)return r();const o=s[i];if(!o.afterChain)return finish(i+1);o.afterChain(e,t,()=>finish(i+1))},o=[];e.authenticators=o;const run=r=>{if(r>=s.length)return finish(0);const i=s[r];i.authenticate(e,t,()=>{i.vouchedFor(e)&&o.push(i.name),run(r+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 r of e){if(!r.pathPrefixes||0===r.pathPrefixes.length)return[];t.push(...r.pathPrefixes)}const r=this.routerStack(),i=new Set;for(const e of r||[]){const t=e.route?.path;"string"==typeof t?i.add(t):Array.isArray(t)&&t.forEach(e=>"string"==typeof e&&i.add(e))}const s=[...i].filter(e=>!t.some(t=>e===t||e.startsWith(`${t}/`)));return s.length>0&&this.logger.warn(`Authentication is restricted to ${t.join(", ")}, so these routes are served without authentication: ${s.sort().join(", ")}`),s.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,r,i)=>{let s="statusCode"in e?e.statusCode:500,o=e.message;if(500===s&&/\[authz-forbidden\]/.test(e.message)?(s=403,o=c):403===s&&/\[authz-forbidden\]/.test(e.message)&&(o=c),s>=400&&s<500)return this.logger.warn(`Client error ${s} in request ${t.method} ${t.path}: ${o}`),void(r.headersSent||r.status(s).json(p(e,o)));this.logger.error(`Server error ${s} in request ${t.method} ${t.path}: ${e.message}`,{stack:e.stack,url:t.url,method:t.method}),r.headersSent||r.status(s).json({error:"Internal Server Error",message:this.config.debug?e.message:"An error occurred"})},this.app.use(this._errorMiddleware),this._errorMiddlewareSetup=!0}run(){return process.on("uncaughtException",e=>{this.logger.error(`Uncaught exception: ${e.message}`,{stack:e.stack})}),process.on("unhandledRejection",(e,t)=>{this.logger.error(`Unhandled promise rejection: ${e}`,{reason:e,promise:t})}),new Promise((e,t)=>{this.server=this.app.listen(this.config.port,r=>{r?(this.logger.error(`Error starting server: ${r}`),t(r)):(this.logger.debug(`Server started on port ${this.config.port}`),e())})})}stop(){this.app.removeAllListeners(),this.server?.close(),this.logger.debug("Server stopped")}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(r(e(import.meta.url)),"..","plugins")+"/").replace(/$/,".js"):i.startsWith(".")?e(new URL(i,import.meta.url)):i}loadPlugin(e){let t,r;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{r=JSON.parse(i),"object"!=typeof r?(this.logger.error(`Overrides for plugin ${e} are not valid: ${i}`),r=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}`),r=void 0}}else t=i,t||(t=void 0)}else t=void 0;const i=r?void 0:`${this.resolveModule(e)}\0${t??""}`,s=void 0===i?void 0:this.pluginLoads.get(i);if(s)return this.logger.debug(`Plugin ${e} already loaded, skipping`),s;const o=this.importPlugin(e,t,r);if(void 0!==i){this.pluginLoads.set(i,o);const forget=()=>{this.pluginLoads.get(i)===o&&this.pluginLoads.delete(i)};o.then(e=>e||forget(),forget)}return o}importPlugin(e,t,r){return this.logger.debug(`Loading plugin ${e}`),e.startsWith("core/")&&(e=this.resolveModule(e)),new Promise((i,s)=>{import(e).then(async o=>{let n;if(o&&o.default&&(o=o.default),r){const e={...this.config,...r};n=new o(this.withConfig(e))}else n=new o(this);if(!n)return s(new Error(`Unable to load ${e}`));i(await this.registerPlugin(e,n,t)),this.logger.debug(`Plugin ${n.name} loaded`)}).catch(t=>s(new Error(`Failed to load plugin ${e}: ${t}`)))})}async admitToComposition(e){this.loadedPlugins[e.name]=e;try{await g(this,e)}finally{delete this.loadedPlugins[e.name]}}async registerPlugin(e,t,r){if(t.name||(t.name=e),r&&(t.name=r),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 r=t.hooks[e];if(this.hooks[e]||(this.hooks[e]=[]),!r||"function"!=typeof r)throw new Error(`Plugin ${t.name}: hook ${e} is invalid`);this.hooks[e].push(r),f(r,t.name,e)}return this.loadedPlugins[t.name]=t,!0}}export{DM,f as recordHookOwner};
@@ -1 +1 @@
1
- import{fileURLToPath as e}from"url";import{join as _,dirname as a}from"path";const t=[["--port","DM_PORT",8081,"number"],["--plugin","DM_PLUGINS",[],"array","--plugins","identifiers"],["--log-level","DM_LOG_LEVEL","notice"],["--logger","DM_LOGGER","console"],["--api-prefix","DM_API_PREFIX","/api"],["--mail-domain","DM_MAIL_DOMAIN",[],"array","--mail-domains","identifiers"],["--ldap-base","DM_LDAP_BASE",""],["--ldap-dn","DM_LDAP_DN","cn=admin,dc=example,dc=com"],["--ldap-pwd","DM_LDAP_PWD","admin"],["--ldap-url","DM_LDAP_URL",["ldap://localhost"],"array","--ldap-urls","identifiers",!0],["--ldap-user-main-attribute","DM_LDAP_USER_ATTRIBUTE","uid"],["--ldap-cache-max","DM_LDAP_CACHE_MAX",1e3,"number"],["--ldap-cache-ttl","DM_LDAP_CACHE_TTL",0,"number"],["--ldap-pool-size","DM_LDAP_POOL_SIZE",5,"number"],["--ldap-connection-ttl","DM_LDAP_CONNECTION_TTL",60,"number"],["--schemas-path","DM_SCHEMAS_PATH",_(a(e(import.meta.url)),"..","..","static","schemas")],["--mail-attribute","DM_MAIL_ATTRIBUTE","mail"],["--quota-attribute","DM_QUOTA_ATTRIBUTE","mailQuotaSize"],["--delegation-attribute","DM_DELEGATION_ATTRIBUTE","twakeDelegatedUsers"],["--alias-attribute","DM_ALIAS_ATTRIBUTE","mailAlternateAddress"],["--forward-attribute","DM_FORWARD_ATTRIBUTE","mailForwardingAddress"],["--display-name-attribute","DM_DISPLAY_NAME_ATTRIBUTE","displayName"],["--drive-quota-attribute","DM_DRIVE_QUOTA_ATTRIBUTE","twakeDriveQuota"],["--user-class","DM_USER_CLASSES",["top","twakeAccount","twakeWhitePages"],"array","--user-classes","identifiers"],["--ldap-top-organization","DM_LDAP_TOP_ORGANIZATION",""],["--ldap-organization-class","DM_LDAP_ORGANIZATION_CLASSES",["top","organizationalUnit","twakeDepartment"],"array","--ldap-organization-classes","identifiers"],["--ldap-organization-link-attribute","DM_LDAP_ORGANIZATION_LINK_ATTRIBUTE","twakeDepartmentLink"],["--ldap-organization-path-attribute","DM_LDAP_ORGANIZATION_PATH_ATTRIBUTE","twakeDepartmentPath"],["--ldap-organization-path-separator","DM_LDAP_ORGANIZATION_PATH_SEPARATOR"," / "],["--ldap-organization-max-subnodes","DM_LDAP_ORGANIZATION_MAX_SUBNODES",50,"number"],["--organization-schema","DM_ORGANIZATION_SCHEMA",""],["--enterprise-domain-name-attribute","DM_ENTERPRISE_DOMAIN_NAME_ATTRIBUTE","associatedDomain"],["--enterprise-domain-link-attribute","DM_ENTERPRISE_DOMAIN_LINK_ATTRIBUTE",""],["--storage-backend","DM_STORAGE_BACKEND",""],["--storage-sweep-interval","DM_STORAGE_SWEEP_INTERVAL",600,"number"],["--storage-ldap-base","DM_STORAGE_LDAP_BASE",""],["--storage-ldap-object-class","DM_STORAGE_LDAP_OBJECT_CLASS","applicationProcess"],["--storage-file-directory","DM_STORAGE_FILE_DIRECTORY",""],["--storage-postgres-url","DM_STORAGE_POSTGRES_URL",""],["--storage-postgres-table","DM_STORAGE_POSTGRES_TABLE","ldap_rest_storage"],["--storage-valkey-url","DM_STORAGE_VALKEY_URL",""],["--storage-valkey-prefix","DM_STORAGE_VALKEY_PREFIX","ldap-rest:"],["--bcl-retention","DM_BCL_RETENTION",604800,"number"],["--ldap-group-base","DM_LDAP_GROUP_BASE",""],["--ldap-groups-main-attribute","DM_LDAP_GROUPS_MAIN_ATTRIBUTE","cn"],["--group-class","DM_GROUP_CLASSES",["top","groupOfNames"],"array","--group-classes","identifiers"],["--group-allow-unexistent-members","DM_ALLOW_UNEXISTENT_MEMBERS",!1,"boolean"],["--group-default-attributes","DM_GROUP_DEFAULT_ATTRIBUTES",{},"json"],["--group-dummy-user","DM_GROUP_DUMMY_USER","cn=fakeuser"],["--group-schema","DM_GROUP_SCHEMA",_(a(e(import.meta.url)),"..","..","static","schemas","twake","groups.json")],["--external-members-branch","DM_EXTERNAL_MEMBERS_BRANCH","ou=contacts,dc=example,dc=com"],["--external-branch-class","DM_EXTERNAL_BRANCH_CLASSES",["top","inetOrgPerson"],"array","--external-branch-classes","identifiers"],["--static-path","DM_STATIC_PATH",_(a(e(import.meta.url)),"..","..","static")],["--static-name","DM_STATIC_NAME","static"],["--ldap-flat-schema","DM_LDAP_FLAT_SCHEMA",[],"array","--ldap-flat-schemas"],["--ldap-flat-auto-repair","DM_LDAP_FLAT_AUTO_REPAIR",!0,"boolean"],["--ldap-raw-base","DM_LDAP_RAW_BASE",[],"array","--ldap-raw-bases","dns"],["--ldap-raw-hidden-attribute","DM_LDAP_RAW_HIDDEN_ATTRIBUTES",[],"array","--ldap-raw-hidden-attributes","identifiers"],["--ldap-raw-show-secrets","DM_LDAP_RAW_SHOW_SECRETS",!1,"boolean"],["--ldap-raw-max-results","DM_LDAP_RAW_MAX_RESULTS",200,"number"],["--ldap-raw-schema-cache-ttl","DM_LDAP_RAW_SCHEMA_CACHE_TTL",3600,"number"],["--bulk-import-schemas","DM_BULK_IMPORT_SCHEMAS",""],["--bulk-import-max-file-size","DM_BULK_IMPORT_MAX_FILE_SIZE","10485760"],["--bulk-import-batch-size","DM_BULK_IMPORT_BATCH_SIZE","100"],["--james-webadmin-url","DM_JAMES_WEBADMIN_URL","http://localhost:8000"],["--james-webadmin-token","DM_JAMES_WEBADMIN_TOKEN",""],["--james-signature-template","DM_JAMES_SIGNATURE_TEMPLATE",""],["--ldap-concurrency","DM_LDAP_CONCURRENCY",10,"number"],["--james-concurrency","DM_JAMES_CONCURRENCY",10,"number"],["--james-init-delay","DM_JAMES_INIT_DELAY",1e3,"number"],["--james-retry-attempts","DM_JAMES_RETRY_ATTEMPTS",5,"number"],["--james-retry-delay","DM_JAMES_RETRY_DELAY",1e3,"number"],["--james-mailing-list-branch","DM_JAMES_MAILING_LIST_BRANCHES",[],"array","--james-mailing-list-branches","dns"],["--james-mailbox-type-attribute","DM_JAMES_MAILBOX_TYPE_ATTRIBUTE","twakeMailboxType"],["--james-sync-forwards","DM_JAMES_SYNC_FORWARDS",!1,"boolean"],["--james-team-mailbox-any-domain","DM_JAMES_TEAM_MAILBOX_ANY_DOMAIN",!1,"boolean"],["--twake-drive-webadmin-url","DM_TWAKE_DRIVE_WEBADMIN_URL",""],["--twake-drive-webadmin-token","DM_TWAKE_DRIVE_WEBADMIN_TOKEN",""],["--twake-drive-concurrency","DM_TWAKE_DRIVE_CONCURRENCY",10,"number"],["--twake-drive-domain-attribute","DM_TWAKE_DRIVE_DOMAIN_ATTRIBUTE","twakeCozyDomain"],["--twake-drive-default-domain-template","DM_TWAKE_DRIVE_DEFAULT_DOMAIN_TEMPLATE",""],["--calendar-webadmin-url","DM_CALENDAR_WEBADMIN_URL","http://localhost:8080"],["--calendar-webadmin-token","DM_CALENDAR_WEBADMIN_TOKEN",""],["--calendar-concurrency","DM_CALENDAR_CONCURRENCY",10,"number"],["--calendar-resource-base","DM_CALENDAR_RESOURCE_BASE",""],["--calendar-resource-objectclass","DM_CALENDAR_RESOURCE_OBJECTCLASS",""],["--calendar-resource-creator","DM_CALENDAR_RESOURCE_CREATOR",""],["--calendar-resource-domain","DM_CALENDAR_RESOURCE_DOMAIN",""],["--calendar-firstname-attribute","DM_CALENDAR_FIRSTNAME_ATTRIBUTE","givenName"],["--calendar-lastname-attribute","DM_CALENDAR_LASTNAME_ATTRIBUTE","sn"],["--cozy-admin-url","DM_COZY_ADMIN_URL",""],["--cozy-admin-user","DM_COZY_ADMIN_USER","admin"],["--cozy-admin-passphrase","DM_COZY_ADMIN_PASSPHRASE",""],["--cozy-org-id","DM_COZY_ORG_ID",""],["--cozy-org-domain","DM_COZY_ORG_DOMAIN",""],["--cozy-default-locale","DM_COZY_DEFAULT_LOCALE","fr"],["--cozy-context-name","DM_COZY_CONTEXT_NAME","default"],["--cozy-apps","DM_COZY_APPS","home,drive,settings,notes,dataproxy"],["--cozy-auth-exchange","DM_COZY_AUTH_EXCHANGE","auth"],["--cozy-b2b-exchange","DM_COZY_B2B_EXCHANGE","b2b"],["--cozy-user-created-routing-key","DM_COZY_USER_CREATED_ROUTING_KEY","user.created"],["--cozy-user-deleted-routing-key","DM_COZY_USER_DELETED_ROUTING_KEY","domain.user.deleted"],["--cloudery-manager-url","DM_CLOUDERY_MANAGER_URL",""],["--cloudery-manager-token","DM_CLOUDERY_MANAGER_TOKEN",""],["--cloudery-offer","DM_CLOUDERY_OFFER","b2b_twake_default"],["--cloudery-domain","DM_CLOUDERY_DOMAIN",""],["--cloudery-user-branch","DM_CLOUDERY_USER_BRANCH",""],["--cloudery-org-id-header","DM_CLOUDERY_ORG_ID_HEADER","x-cloudery-org-id"],["--cloudery-org-role-header","DM_CLOUDERY_ORG_ROLE_HEADER","x-cloudery-org-role"],["--cloudery-default-org-role","DM_CLOUDERY_DEFAULT_ORG_ROLE","member"],["--cloudery-fqdn-attribute","DM_CLOUDERY_FQDN_ATTRIBUTE","twakeWorkspaceUrl"],["--cloudery-org-id-attribute","DM_CLOUDERY_ORG_ID_ATTRIBUTE","twakeOrganizationId"],["--cloudery-org-role-attribute","DM_CLOUDERY_ORG_ROLE_ATTRIBUTE","twakeOrganizationRole"],["--cloudery-phones-attribute","DM_CLOUDERY_PHONES_ATTRIBUTE","twakePhones"],["--cloudery-invited-attribute","DM_CLOUDERY_INVITED_ATTRIBUTE","twakeInvited"],["--cloudery-default-locale","DM_CLOUDERY_DEFAULT_LOCALE","en"],["--cloudery-workflow-poll-interval-ms","DM_CLOUDERY_WORKFLOW_POLL_INTERVAL_MS",2e3,"number"],["--cloudery-workflow-max-attempts","DM_CLOUDERY_WORKFLOW_MAX_ATTEMPTS",60,"number"],["--twake-lifecycle-role-attribute","DM_TWAKE_LIFECYCLE_ROLE_ATTRIBUTE",""],["--twake-lifecycle-lock-attribute","DM_TWAKE_LIFECYCLE_LOCK_ATTRIBUTE",""],["--twake-lifecycle-lock-value","DM_TWAKE_LIFECYCLE_LOCK_VALUE",""],["--twake-lifecycle-deleted-attribute","DM_TWAKE_LIFECYCLE_DELETED_ATTRIBUTE",""],["--twake-lifecycle-deleted-value","DM_TWAKE_LIFECYCLE_DELETED_VALUE","TRUE"],["--twake-lifecycle-deleted-at-attribute","DM_TWAKE_LIFECYCLE_DELETED_AT_ATTRIBUTE",""],["--twake-lifecycle-deleted-at-format","DM_TWAKE_LIFECYCLE_DELETED_AT_FORMAT","iso8601"],["--twake-lifecycle-reason-attribute","DM_TWAKE_LIFECYCLE_REASON_ATTRIBUTE",""],["--twake-lifecycle-rules","DM_TWAKE_LIFECYCLE_RULES",""],["--twake-lifecycle-member-attribute","DM_TWAKE_LIFECYCLE_MEMBER_ATTRIBUTE","member"],["--twake-group-base","DM_TWAKE_GROUP_BASE",""],["--twake-group-user-base","DM_TWAKE_GROUP_USER_BASE",""],["--twake-group-organization-dn","DM_TWAKE_GROUP_ORGANIZATION_DN",""],["--twake-group-organization-status-attribute","DM_TWAKE_GROUP_ORGANIZATION_STATUS_ATTRIBUTE","twakeOrgStatus"],["--twake-group-organization-deleted-value","DM_TWAKE_GROUP_ORGANIZATION_DELETED_VALUE","deleted"],["--twake-group-display-name-attribute","DM_TWAKE_GROUP_DISPLAY_NAME_ATTRIBUTE","twakeDisplayName"],["--twake-group-color-attribute","DM_TWAKE_GROUP_COLOR_ATTRIBUTE","twakeGroupColor"],["--twake-group-created-at-attribute","DM_TWAKE_GROUP_CREATED_AT_ATTRIBUTE","twakeCreatedAt"],["--twake-group-max-page-limit","DM_TWAKE_GROUP_MAX_PAGE_LIMIT",1e3,"number"],["--twake-group-member-fields","DM_TWAKE_GROUP_MEMBER_FIELDS",{},"json"],["--twake-space-base","DM_TWAKE_SPACE_BASE",""],["--twake-space-class","DM_TWAKE_SPACE_CLASS",["top","twakeSpace"],"array",null,"identifiers"],["--twake-space-display-name-attribute","DM_TWAKE_SPACE_DISPLAY_NAME_ATTRIBUTE","twakeDisplayName"],["--twake-space-admin-attribute","DM_TWAKE_SPACE_ADMIN_ATTRIBUTE","twakeSpaceAdmin"],["--twake-space-editor-attribute","DM_TWAKE_SPACE_EDITOR_ATTRIBUTE","twakeSpaceEditor"],["--twake-space-viewer-attribute","DM_TWAKE_SPACE_VIEWER_ATTRIBUTE","twakeSpaceViewer"],["--twake-space-user-role-attribute","DM_TWAKE_SPACE_USER_ROLE_ATTRIBUTE",""],["--twake-space-exchange","DM_TWAKE_SPACE_EXCHANGE","space"],["--twake-space-sync-queue","DM_TWAKE_SPACE_SYNC_QUEUE","twake.space.sync.requested.ldap-rest"],["--twake-tombstone-dn","DM_TWAKE_TOMBSTONE_DN",[],"array",null,"dns"],["--twake-tombstone-default-reason","DM_TWAKE_TOMBSTONE_DEFAULT_REASON","deleted"],["--twake-tombstone-reason-header","DM_TWAKE_TOMBSTONE_REASON_HEADER","x-deletion-reason"],["--twake-tombstone-reasons","DM_TWAKE_TOMBSTONE_REASONS",[],"array",null,"identifiers"],["--twake-tombstone-clear-attributes","DM_TWAKE_TOMBSTONE_CLEAR_ATTRIBUTES",[],"array",null,"identifiers"],["--twake-tombstone-erase-min-age","DM_TWAKE_TOMBSTONE_ERASE_MIN_AGE",2592e3,"number"],["--twake-tombstone-group-bases","DM_TWAKE_TOMBSTONE_GROUP_BASES",[],"array",null,"dns"],["--twake-instance-dn","DM_TWAKE_INSTANCE_DN",[],"array",null,"dns"],["--twake-instance-skip-attribute","DM_TWAKE_INSTANCE_SKIP_ATTRIBUTE",""],["--twake-instance-skip-value","DM_TWAKE_INSTANCE_SKIP_VALUE",""],["--twake-instance-provider","DM_TWAKE_INSTANCE_PROVIDER","cloudery"],["--twake-instance-id","DM_TWAKE_INSTANCE_ID","{uid}"],["--twake-instance-fqdn-attribute","DM_TWAKE_INSTANCE_FQDN_ATTRIBUTE","twakeWorkspaceUrl"],["--twake-instance-sent-attribute","DM_TWAKE_INSTANCE_SENT_ATTRIBUTE","twakeCreatedEventAt"],["--twake-instance-locale","DM_TWAKE_INSTANCE_LOCALE",""],["--twake-instance-timeout","DM_TWAKE_INSTANCE_TIMEOUT",3e4,"number"],["--twake-instance-cloudery-url","DM_TWAKE_INSTANCE_CLOUDERY_URL",""],["--twake-instance-cloudery-token","DM_TWAKE_INSTANCE_CLOUDERY_TOKEN",""],["--twake-instance-cloudery-offer","DM_TWAKE_INSTANCE_CLOUDERY_OFFER","b2b_twake_default"],["--twake-instance-cloudery-domain","DM_TWAKE_INSTANCE_CLOUDERY_DOMAIN",""],["--twake-instance-cloudery-organization-offer","DM_TWAKE_INSTANCE_CLOUDERY_ORGANIZATION_OFFER",""],["--twake-instance-cozy-url","DM_TWAKE_INSTANCE_COZY_URL",""],["--twake-instance-cozy-user","DM_TWAKE_INSTANCE_COZY_USER","admin"],["--twake-instance-cozy-passphrase","DM_TWAKE_INSTANCE_COZY_PASSPHRASE",""],["--twake-instance-cozy-domain","DM_TWAKE_INSTANCE_COZY_DOMAIN",""],["--twake-instance-cozy-context","DM_TWAKE_INSTANCE_COZY_CONTEXT","default"],["--twake-instance-cozy-apps","DM_TWAKE_INSTANCE_COZY_APPS","home,drive,settings,notes,dataproxy"],["--twake-instance-cozy-org-id","DM_TWAKE_INSTANCE_COZY_ORG_ID",""],["--twake-instance-cozy-org-domain","DM_TWAKE_INSTANCE_COZY_ORG_DOMAIN",""],["--twake-instance-auth-exchange","DM_TWAKE_INSTANCE_AUTH_EXCHANGE","auth"],["--twake-instance-b2b-exchange","DM_TWAKE_INSTANCE_B2B_EXCHANGE","b2b"],["--twake-instance-user-created-key","DM_TWAKE_INSTANCE_USER_CREATED_KEY","user.created"],["--twake-instance-organization-created-key","DM_TWAKE_INSTANCE_ORGANIZATION_CREATED_KEY","organization.created"],["--twake-instance-workplace-created-key","DM_TWAKE_INSTANCE_WORKPLACE_CREATED_KEY","workplace.created"],["--twake-instance-queue","DM_TWAKE_INSTANCE_QUEUE","workplace.created.ldap-rest"],["--twake-instance-organization-base","DM_TWAKE_INSTANCE_ORGANIZATION_BASE",""],["--twake-instance-organization-id-attribute","DM_TWAKE_INSTANCE_ORGANIZATION_ID_ATTRIBUTE","ou"],["--twake-instance-organization-domain-attribute","DM_TWAKE_INSTANCE_ORGANIZATION_DOMAIN_ATTRIBUTE","twakeDomain"],["--twake-instance-organization-name-attribute","DM_TWAKE_INSTANCE_ORGANIZATION_NAME_ATTRIBUTE","description"],["--twake-instance-organization-fqdn-attribute","DM_TWAKE_INSTANCE_ORGANIZATION_FQDN_ATTRIBUTE",""],["--rabbitmq-url","DM_RABBITMQ_URL",""],["--applicative-account-base","DM_APPLICATIVE_ACCOUNT_BASE",""],["--max-app-accounts","DM_MAX_APP_ACCOUNTS",5,"number"],["--applicative-account-attribute","DM_APPLICATIVE_ACCOUNT_ATTRIBUTES",["objectClass","cn","sn","givenName","displayName","description"],"array","--applicative-account-attributes","identifiers"],["--ldap-operational-attribute","DM_LDAP_OPERATIONAL_ATTRIBUTES",["dn","controls","structuralObjectClass","entryUUID","entryDN","subschemaSubentry","modifyTimestamp","modifiersName","createTimestamp","creatorsName","userPassword"],"array","--ldap-operational-attributes","identifiers"],["--trash-base","DM_TRASH_BASE",""],["--trash-watched-bases","DM_TRASH_WATCHED_BASES",""],["--trash-add-metadata","DM_TRASH_ADD_METADATA","true"],["--trash-auto-create","DM_TRASH_AUTO_CREATE","true"],["--llng-ini","DM_LLNG_INI","/etc/lemonldap-ng/lemonldap-ng.ini"],["--llng-username-header","DM_LLNG_USERNAME_HEADER",""],["--auth-path-prefix","DM_AUTH_PATH_PREFIX",[],"array","--auth-path-prefixes"],["--auth-token","DM_AUTH_TOKENS",[],"array","--auth-tokens"],["--auth-fake-user","DM_AUTH_FAKE_USER",""],["--auth-totp","DM_AUTH_TOTP",[],"array","--auth-totps","phrases"],["--auth-totp-window","DM_AUTH_TOTP_WINDOW",1,"number"],["--auth-totp-step","DM_AUTH_TOTP_STEP",30,"number"],["--auth-hmac","DM_AUTH_HMAC",[],"array","--auth-hmacs","phrases"],["--auth-hmac-window","DM_AUTH_HMAC_WINDOW",12e4,"number"],["--authz-per-route","DM_AUTHZ_PER_ROUTES",[],"array","--authz-per-routes"],["--authz-unresolved-user","DM_AUTHZ_UNRESOLVED_USER","deny"],["--authz-identity","DM_AUTHZ_IDENTITY","req.user"],["--authz-for","DM_AUTHZ_FOR",[],"array",null,"identifiers"],["--authz-combine","DM_AUTHZ_COMBINE",!1,"boolean"],["--authz-scope-source","DM_AUTHZ_SCOPE_SOURCE",""],["--authz-filter-attached-entries","DM_AUTHZ_FILTER_ATTACHED_ENTRIES",!1,"boolean"],["--authz-transit-branch","DM_AUTHZ_TRANSIT_BRANCH",""],["--authz-per-branch-config","DM_AUTHZ_PER_BRANCH_CONFIG",{default:{read:!0,write:!1,delete:!1}},"json"],["--authz-per-branch-cache-ttl","DM_AUTHZ_PER_BRANCH_CACHE_TTL",60,"number"],["--authz-dynamic-bypass","DM_AUTHZ_DYNAMIC_BYPASS",[],"array",null,"identifiers"],["--authz-dynamic-base","DM_AUTHZ_DYNAMIC_BASE",""],["--authz-dynamic-cache-ttl","DM_AUTHZ_DYNAMIC_CACHE_TTL",60,"number"],["--authz-dynamic-token-attribute","DM_AUTHZ_DYNAMIC_TOKEN_ATTRIBUTE","userPassword"],["--authz-dynamic-config-attribute","DM_AUTHZ_DYNAMIC_CONFIG_ATTRIBUTE","description"],["--authz-dynamic-tenant-attribute","DM_AUTHZ_DYNAMIC_TENANT_ATTRIBUTE","cn"],["--authz-dynamic-reload-endpoint","DM_AUTHZ_DYNAMIC_RELOAD_ENDPOINT",!1,"boolean"],["--authz-local-admin-attribute","DM_AUTHZ_LOCAL_ADMIN_ATTRIBUTE","twakeLocalAdminLink"],["--authz-global-branch","DM_AUTHZ_GLOBAL_BRANCHES",[],"array","--authz-global-branches","dns"],["--authz-global-write-branch","DM_AUTHZ_GLOBAL_WRITE_BRANCHES",[],"array","--authz-global-write-branches","dns"],["--authz-global-organization","DM_AUTHZ_GLOBAL_ORGANIZATION",""],["--oidc-server","DM_OIDC_SERVER",""],["--oidc-username-claim","DM_OIDC_USERNAME_CLAIM","sub"],["--oidc-client-id","DM_OIDC_CLIENT_ID",""],["--oidc-client-secret","DM_OIDC_CLIENT_SECRET",""],["--base-url","DM_BASE_URL",""],["--rate-limit-window-ms","DM_RATE_LIMIT_WINDOW_MS",9e5,"number"],["--rate-limit-max","DM_RATE_LIMIT_MAX",100,"number"],["--crowdsec-url","DM_CROWDSEC_URL","http://localhost:8080/v1/decisions"],["--crowdsec-api-key","DM_CROWDSEC_API_KEY",""],["--crowdsec-cache-ttl","DM_CROWDSEC_CACHE_TTL",60,"number"],["--trusted-proxy","DM_TRUSTED_PROXIES",[],"array","--trusted-proxies","identifiers"],["--trusted-proxy-auth-header","DM_TRUSTED_PROXY_AUTH_HEADER","Auth-User"],["--ppolicy-default-dn","DM_PPOLICY_DEFAULT_DN",""],["--ppolicy-base","DM_PPOLICY_BASE",""],["--ppolicy-warn-days","DM_PPOLICY_WARN_DAYS",14,"number"],["--ppolicy-validate-complexity","DM_PPOLICY_VALIDATE_COMPLEXITY",!1,"boolean"],["--ppolicy-min-length","DM_PPOLICY_MIN_LENGTH",12,"number"],["--ppolicy-require-uppercase","DM_PPOLICY_REQUIRE_UPPERCASE",!0,"boolean"],["--ppolicy-require-lowercase","DM_PPOLICY_REQUIRE_LOWERCASE",!0,"boolean"],["--ppolicy-require-digit","DM_PPOLICY_REQUIRE_DIGIT",!0,"boolean"],["--ppolicy-require-special","DM_PPOLICY_REQUIRE_SPECIAL",!0,"boolean"],["--ldap-users-base","DM_LDAP_USERS_BASE",""],["--scim-prefix","DM_SCIM_PREFIX","/scim/v2"],["--scim-user-base","DM_SCIM_USER_BASE",""],["--scim-group-base","DM_SCIM_GROUP_BASE",""],["--scim-user-base-template","DM_SCIM_USER_BASE_TEMPLATE",""],["--scim-group-base-template","DM_SCIM_GROUP_BASE_TEMPLATE",""],["--scim-base-map","DM_SCIM_BASE_MAP",""],["--scim-user-base-header","DM_SCIM_USER_BASE_HEADER",""],["--scim-group-base-header","DM_SCIM_GROUP_BASE_HEADER",""],["--scim-base-header-root","DM_SCIM_BASE_HEADER_ROOT",""],["--scim-user-object-class","DM_SCIM_USER_OBJECT_CLASSES",["top","inetOrgPerson","organizationalPerson","person"],"array","--scim-user-object-classes","identifiers"],["--scim-user-rdn-attribute","DM_SCIM_USER_RDN_ATTRIBUTE","uid"],["--scim-user-lock-attribute","DM_SCIM_USER_LOCK_ATTRIBUTE","pwdAccountLockedTime"],["--scim-user-lock-value","DM_SCIM_USER_LOCK_VALUE",""],["--scim-group-object-class","DM_SCIM_GROUP_OBJECT_CLASSES",["top","groupOfNames"],"array","--scim-group-object-classes","identifiers"],["--scim-group-rdn-attribute","DM_SCIM_GROUP_RDN_ATTRIBUTE","cn"],["--scim-group-external-id-attribute","DM_SCIM_GROUP_EXTERNAL_ID_ATTRIBUTE",""],["--scim-id-attribute","DM_SCIM_ID_ATTRIBUTE","rdn"],["--scim-user-mapping","DM_SCIM_USER_MAPPING",""],["--scim-group-mapping","DM_SCIM_GROUP_MAPPING",""],["--scim-max-results","DM_SCIM_MAX_RESULTS",200,"number"],["--scim-max-scanned","DM_SCIM_MAX_SCANNED",1e4,"number"],["--scim-bulk-max-operations","DM_SCIM_BULK_MAX_OPERATIONS",100,"number"],["--scim-bulk-max-payload-size","DM_SCIM_BULK_MAX_PAYLOAD_SIZE",1048576,"number"],["--scim-etag","DM_SCIM_ETAG",!1,"boolean"],["--scim-base-url","DM_SCIM_BASE_URL",""]];export{t as default};
1
+ import{fileURLToPath as e}from"url";import{join as _,dirname as a}from"path";const t=[["--port","DM_PORT",8081,"number"],["--plugin","DM_PLUGINS",[],"array","--plugins","identifiers"],["--log-level","DM_LOG_LEVEL","notice"],["--logger","DM_LOGGER","console"],["--api-prefix","DM_API_PREFIX","/api"],["--mail-domain","DM_MAIL_DOMAIN",[],"array","--mail-domains","identifiers"],["--ldap-base","DM_LDAP_BASE",""],["--ldap-dn","DM_LDAP_DN","cn=admin,dc=example,dc=com"],["--ldap-pwd","DM_LDAP_PWD","admin"],["--ldap-url","DM_LDAP_URL",["ldap://localhost"],"array","--ldap-urls","identifiers",!0],["--ldap-user-main-attribute","DM_LDAP_USER_ATTRIBUTE","uid"],["--ldap-cache-max","DM_LDAP_CACHE_MAX",1e3,"number"],["--ldap-cache-ttl","DM_LDAP_CACHE_TTL",0,"number"],["--ldap-pool-size","DM_LDAP_POOL_SIZE",5,"number"],["--ldap-connection-ttl","DM_LDAP_CONNECTION_TTL",60,"number"],["--schemas-path","DM_SCHEMAS_PATH",_(a(e(import.meta.url)),"..","..","static","schemas")],["--mail-attribute","DM_MAIL_ATTRIBUTE","mail"],["--quota-attribute","DM_QUOTA_ATTRIBUTE","mailQuotaSize"],["--delegation-attribute","DM_DELEGATION_ATTRIBUTE","twakeDelegatedUsers"],["--alias-attribute","DM_ALIAS_ATTRIBUTE","mailAlternateAddress"],["--forward-attribute","DM_FORWARD_ATTRIBUTE","mailForwardingAddress"],["--display-name-attribute","DM_DISPLAY_NAME_ATTRIBUTE","displayName"],["--drive-quota-attribute","DM_DRIVE_QUOTA_ATTRIBUTE","twakeDriveQuota"],["--user-class","DM_USER_CLASSES",["top","twakeAccount","twakeWhitePages"],"array","--user-classes","identifiers"],["--ldap-top-organization","DM_LDAP_TOP_ORGANIZATION",""],["--ldap-organization-class","DM_LDAP_ORGANIZATION_CLASSES",["top","organizationalUnit","twakeDepartment"],"array","--ldap-organization-classes","identifiers"],["--ldap-organization-link-attribute","DM_LDAP_ORGANIZATION_LINK_ATTRIBUTE","twakeDepartmentLink"],["--ldap-organization-path-attribute","DM_LDAP_ORGANIZATION_PATH_ATTRIBUTE","twakeDepartmentPath"],["--ldap-organization-path-separator","DM_LDAP_ORGANIZATION_PATH_SEPARATOR"," / "],["--ldap-organization-max-subnodes","DM_LDAP_ORGANIZATION_MAX_SUBNODES",50,"number"],["--organization-schema","DM_ORGANIZATION_SCHEMA",""],["--enterprise-domain-name-attribute","DM_ENTERPRISE_DOMAIN_NAME_ATTRIBUTE","associatedDomain"],["--enterprise-domain-link-attribute","DM_ENTERPRISE_DOMAIN_LINK_ATTRIBUTE",""],["--storage-backend","DM_STORAGE_BACKEND",""],["--storage-sweep-interval","DM_STORAGE_SWEEP_INTERVAL",600,"number"],["--storage-ldap-base","DM_STORAGE_LDAP_BASE",""],["--storage-ldap-object-class","DM_STORAGE_LDAP_OBJECT_CLASS","applicationProcess"],["--storage-file-directory","DM_STORAGE_FILE_DIRECTORY",""],["--storage-postgres-url","DM_STORAGE_POSTGRES_URL",""],["--storage-postgres-table","DM_STORAGE_POSTGRES_TABLE","ldap_rest_storage"],["--storage-valkey-url","DM_STORAGE_VALKEY_URL",""],["--storage-valkey-prefix","DM_STORAGE_VALKEY_PREFIX","ldap-rest:"],["--bcl-retention","DM_BCL_RETENTION",604800,"number"],["--ldap-group-base","DM_LDAP_GROUP_BASE",""],["--ldap-groups-main-attribute","DM_LDAP_GROUPS_MAIN_ATTRIBUTE","cn"],["--group-class","DM_GROUP_CLASSES",["top","groupOfNames"],"array","--group-classes","identifiers"],["--group-allow-unexistent-members","DM_ALLOW_UNEXISTENT_MEMBERS",!1,"boolean"],["--group-default-attributes","DM_GROUP_DEFAULT_ATTRIBUTES",{},"json"],["--group-dummy-user","DM_GROUP_DUMMY_USER","cn=fakeuser"],["--group-schema","DM_GROUP_SCHEMA",_(a(e(import.meta.url)),"..","..","static","schemas","twake","groups.json")],["--external-members-branch","DM_EXTERNAL_MEMBERS_BRANCH","ou=contacts,dc=example,dc=com"],["--external-branch-class","DM_EXTERNAL_BRANCH_CLASSES",["top","inetOrgPerson"],"array","--external-branch-classes","identifiers"],["--static-path","DM_STATIC_PATH",_(a(e(import.meta.url)),"..","..","static")],["--static-name","DM_STATIC_NAME","static"],["--ldap-flat-schema","DM_LDAP_FLAT_SCHEMA",[],"array","--ldap-flat-schemas"],["--ldap-flat-auto-repair","DM_LDAP_FLAT_AUTO_REPAIR",!0,"boolean"],["--ldap-raw-base","DM_LDAP_RAW_BASE",[],"array","--ldap-raw-bases","dns"],["--ldap-raw-hidden-attribute","DM_LDAP_RAW_HIDDEN_ATTRIBUTES",[],"array","--ldap-raw-hidden-attributes","identifiers"],["--ldap-raw-show-secrets","DM_LDAP_RAW_SHOW_SECRETS",!1,"boolean"],["--ldap-raw-max-results","DM_LDAP_RAW_MAX_RESULTS",200,"number"],["--ldap-raw-schema-cache-ttl","DM_LDAP_RAW_SCHEMA_CACHE_TTL",3600,"number"],["--bulk-import-schemas","DM_BULK_IMPORT_SCHEMAS",""],["--bulk-import-max-file-size","DM_BULK_IMPORT_MAX_FILE_SIZE","10485760"],["--bulk-import-batch-size","DM_BULK_IMPORT_BATCH_SIZE","100"],["--james-webadmin-url","DM_JAMES_WEBADMIN_URL","http://localhost:8000"],["--james-webadmin-token","DM_JAMES_WEBADMIN_TOKEN",""],["--james-signature-template","DM_JAMES_SIGNATURE_TEMPLATE",""],["--ldap-concurrency","DM_LDAP_CONCURRENCY",10,"number"],["--james-concurrency","DM_JAMES_CONCURRENCY",10,"number"],["--james-init-delay","DM_JAMES_INIT_DELAY",1e3,"number"],["--james-retry-attempts","DM_JAMES_RETRY_ATTEMPTS",5,"number"],["--james-retry-delay","DM_JAMES_RETRY_DELAY",1e3,"number"],["--james-mailing-list-branch","DM_JAMES_MAILING_LIST_BRANCHES",[],"array","--james-mailing-list-branches","dns"],["--james-mailbox-type-attribute","DM_JAMES_MAILBOX_TYPE_ATTRIBUTE","twakeMailboxType"],["--james-sync-forwards","DM_JAMES_SYNC_FORWARDS",!1,"boolean"],["--james-team-mailbox-any-domain","DM_JAMES_TEAM_MAILBOX_ANY_DOMAIN",!1,"boolean"],["--twake-drive-webadmin-url","DM_TWAKE_DRIVE_WEBADMIN_URL",""],["--twake-drive-webadmin-token","DM_TWAKE_DRIVE_WEBADMIN_TOKEN",""],["--twake-drive-concurrency","DM_TWAKE_DRIVE_CONCURRENCY",10,"number"],["--twake-drive-domain-attribute","DM_TWAKE_DRIVE_DOMAIN_ATTRIBUTE","twakeCozyDomain"],["--twake-drive-default-domain-template","DM_TWAKE_DRIVE_DEFAULT_DOMAIN_TEMPLATE",""],["--calendar-webadmin-url","DM_CALENDAR_WEBADMIN_URL","http://localhost:8080"],["--calendar-webadmin-token","DM_CALENDAR_WEBADMIN_TOKEN",""],["--calendar-concurrency","DM_CALENDAR_CONCURRENCY",10,"number"],["--calendar-resource-base","DM_CALENDAR_RESOURCE_BASE",""],["--calendar-resource-objectclass","DM_CALENDAR_RESOURCE_OBJECTCLASS",""],["--calendar-resource-creator","DM_CALENDAR_RESOURCE_CREATOR",""],["--calendar-resource-domain","DM_CALENDAR_RESOURCE_DOMAIN",""],["--calendar-firstname-attribute","DM_CALENDAR_FIRSTNAME_ATTRIBUTE","givenName"],["--calendar-lastname-attribute","DM_CALENDAR_LASTNAME_ATTRIBUTE","sn"],["--cozy-admin-url","DM_COZY_ADMIN_URL",""],["--cozy-admin-user","DM_COZY_ADMIN_USER","admin"],["--cozy-admin-passphrase","DM_COZY_ADMIN_PASSPHRASE",""],["--cozy-org-id","DM_COZY_ORG_ID",""],["--cozy-org-domain","DM_COZY_ORG_DOMAIN",""],["--cozy-default-locale","DM_COZY_DEFAULT_LOCALE","fr"],["--cozy-context-name","DM_COZY_CONTEXT_NAME","default"],["--cozy-apps","DM_COZY_APPS","home,drive,settings,notes,dataproxy"],["--cozy-auth-exchange","DM_COZY_AUTH_EXCHANGE","auth"],["--cozy-b2b-exchange","DM_COZY_B2B_EXCHANGE","b2b"],["--cozy-user-created-routing-key","DM_COZY_USER_CREATED_ROUTING_KEY","user.created"],["--cozy-user-deleted-routing-key","DM_COZY_USER_DELETED_ROUTING_KEY","domain.user.deleted"],["--cloudery-manager-url","DM_CLOUDERY_MANAGER_URL",""],["--cloudery-manager-token","DM_CLOUDERY_MANAGER_TOKEN",""],["--cloudery-offer","DM_CLOUDERY_OFFER","b2b_twake_default"],["--cloudery-domain","DM_CLOUDERY_DOMAIN",""],["--cloudery-user-branch","DM_CLOUDERY_USER_BRANCH",""],["--cloudery-org-id-header","DM_CLOUDERY_ORG_ID_HEADER","x-cloudery-org-id"],["--cloudery-org-role-header","DM_CLOUDERY_ORG_ROLE_HEADER","x-cloudery-org-role"],["--cloudery-default-org-role","DM_CLOUDERY_DEFAULT_ORG_ROLE","member"],["--cloudery-fqdn-attribute","DM_CLOUDERY_FQDN_ATTRIBUTE","twakeWorkspaceUrl"],["--cloudery-org-id-attribute","DM_CLOUDERY_ORG_ID_ATTRIBUTE","twakeOrganizationId"],["--cloudery-org-role-attribute","DM_CLOUDERY_ORG_ROLE_ATTRIBUTE","twakeOrganizationRole"],["--cloudery-phones-attribute","DM_CLOUDERY_PHONES_ATTRIBUTE","twakePhones"],["--cloudery-invited-attribute","DM_CLOUDERY_INVITED_ATTRIBUTE","twakeInvited"],["--cloudery-default-locale","DM_CLOUDERY_DEFAULT_LOCALE","en"],["--cloudery-workflow-poll-interval-ms","DM_CLOUDERY_WORKFLOW_POLL_INTERVAL_MS",2e3,"number"],["--cloudery-workflow-max-attempts","DM_CLOUDERY_WORKFLOW_MAX_ATTEMPTS",60,"number"],["--twake-lifecycle-role-attribute","DM_TWAKE_LIFECYCLE_ROLE_ATTRIBUTE",""],["--twake-lifecycle-lock-attribute","DM_TWAKE_LIFECYCLE_LOCK_ATTRIBUTE",""],["--twake-lifecycle-lock-value","DM_TWAKE_LIFECYCLE_LOCK_VALUE",""],["--twake-lifecycle-deleted-attribute","DM_TWAKE_LIFECYCLE_DELETED_ATTRIBUTE",""],["--twake-lifecycle-deleted-value","DM_TWAKE_LIFECYCLE_DELETED_VALUE","TRUE"],["--twake-lifecycle-deleted-at-attribute","DM_TWAKE_LIFECYCLE_DELETED_AT_ATTRIBUTE",""],["--twake-lifecycle-deleted-at-format","DM_TWAKE_LIFECYCLE_DELETED_AT_FORMAT","iso8601"],["--twake-lifecycle-reason-attribute","DM_TWAKE_LIFECYCLE_REASON_ATTRIBUTE",""],["--twake-lifecycle-rules","DM_TWAKE_LIFECYCLE_RULES",""],["--twake-lifecycle-member-attribute","DM_TWAKE_LIFECYCLE_MEMBER_ATTRIBUTE","member"],["--twake-group-base","DM_TWAKE_GROUP_BASE",""],["--twake-group-user-base","DM_TWAKE_GROUP_USER_BASE",""],["--twake-group-organization-dn","DM_TWAKE_GROUP_ORGANIZATION_DN",""],["--twake-group-organization-status-attribute","DM_TWAKE_GROUP_ORGANIZATION_STATUS_ATTRIBUTE","twakeOrgStatus"],["--twake-group-organization-deleted-value","DM_TWAKE_GROUP_ORGANIZATION_DELETED_VALUE","deleted"],["--twake-group-display-name-attribute","DM_TWAKE_GROUP_DISPLAY_NAME_ATTRIBUTE","twakeDisplayName"],["--twake-group-color-attribute","DM_TWAKE_GROUP_COLOR_ATTRIBUTE","twakeGroupColor"],["--twake-group-created-at-attribute","DM_TWAKE_GROUP_CREATED_AT_ATTRIBUTE","twakeCreatedAt"],["--twake-group-max-page-limit","DM_TWAKE_GROUP_MAX_PAGE_LIMIT",1e3,"number"],["--twake-group-member-fields","DM_TWAKE_GROUP_MEMBER_FIELDS",{},"json"],["--twake-space-base","DM_TWAKE_SPACE_BASE",""],["--twake-space-class","DM_TWAKE_SPACE_CLASS",["top","twakeSpace"],"array",null,"identifiers"],["--twake-space-display-name-attribute","DM_TWAKE_SPACE_DISPLAY_NAME_ATTRIBUTE","twakeDisplayName"],["--twake-space-admin-attribute","DM_TWAKE_SPACE_ADMIN_ATTRIBUTE","twakeSpaceAdmin"],["--twake-space-editor-attribute","DM_TWAKE_SPACE_EDITOR_ATTRIBUTE","twakeSpaceEditor"],["--twake-space-viewer-attribute","DM_TWAKE_SPACE_VIEWER_ATTRIBUTE","twakeSpaceViewer"],["--twake-space-user-role-attribute","DM_TWAKE_SPACE_USER_ROLE_ATTRIBUTE",""],["--twake-space-exchange","DM_TWAKE_SPACE_EXCHANGE","space"],["--twake-space-sync-queue","DM_TWAKE_SPACE_SYNC_QUEUE","twake.space.sync.requested.ldap-rest"],["--twake-tombstone-dn","DM_TWAKE_TOMBSTONE_DN",[],"array",null,"dns"],["--twake-tombstone-default-reason","DM_TWAKE_TOMBSTONE_DEFAULT_REASON","deleted"],["--twake-tombstone-reason-header","DM_TWAKE_TOMBSTONE_REASON_HEADER","x-deletion-reason"],["--twake-tombstone-reasons","DM_TWAKE_TOMBSTONE_REASONS",[],"array",null,"identifiers"],["--twake-tombstone-clear-attributes","DM_TWAKE_TOMBSTONE_CLEAR_ATTRIBUTES",[],"array",null,"identifiers"],["--twake-tombstone-erase-min-age","DM_TWAKE_TOMBSTONE_ERASE_MIN_AGE",2592e3,"number"],["--twake-tombstone-group-bases","DM_TWAKE_TOMBSTONE_GROUP_BASES",[],"array",null,"dns"],["--twake-instance-dn","DM_TWAKE_INSTANCE_DN",[],"array",null,"dns"],["--twake-instance-skip-attribute","DM_TWAKE_INSTANCE_SKIP_ATTRIBUTE",""],["--twake-instance-skip-value","DM_TWAKE_INSTANCE_SKIP_VALUE",""],["--twake-instance-provider","DM_TWAKE_INSTANCE_PROVIDER","cloudery"],["--twake-instance-id","DM_TWAKE_INSTANCE_ID","{uid}"],["--twake-instance-fqdn-attribute","DM_TWAKE_INSTANCE_FQDN_ATTRIBUTE","twakeWorkspaceUrl"],["--twake-instance-sent-attribute","DM_TWAKE_INSTANCE_SENT_ATTRIBUTE","twakeCreatedEventAt"],["--twake-instance-locale","DM_TWAKE_INSTANCE_LOCALE",""],["--twake-instance-timeout","DM_TWAKE_INSTANCE_TIMEOUT",3e4,"number"],["--twake-instance-cloudery-url","DM_TWAKE_INSTANCE_CLOUDERY_URL",""],["--twake-instance-cloudery-token","DM_TWAKE_INSTANCE_CLOUDERY_TOKEN",""],["--twake-instance-cloudery-offer","DM_TWAKE_INSTANCE_CLOUDERY_OFFER","b2b_twake_default"],["--twake-instance-cloudery-domain","DM_TWAKE_INSTANCE_CLOUDERY_DOMAIN",""],["--twake-instance-cloudery-organization-offer","DM_TWAKE_INSTANCE_CLOUDERY_ORGANIZATION_OFFER",""],["--twake-instance-cozy-url","DM_TWAKE_INSTANCE_COZY_URL",""],["--twake-instance-cozy-user","DM_TWAKE_INSTANCE_COZY_USER","admin"],["--twake-instance-cozy-passphrase","DM_TWAKE_INSTANCE_COZY_PASSPHRASE",""],["--twake-instance-cozy-domain","DM_TWAKE_INSTANCE_COZY_DOMAIN",""],["--twake-instance-cozy-context","DM_TWAKE_INSTANCE_COZY_CONTEXT","default"],["--twake-instance-cozy-apps","DM_TWAKE_INSTANCE_COZY_APPS","home,drive,settings,notes,dataproxy"],["--twake-instance-cozy-org-id","DM_TWAKE_INSTANCE_COZY_ORG_ID",""],["--twake-instance-cozy-org-domain","DM_TWAKE_INSTANCE_COZY_ORG_DOMAIN",""],["--twake-instance-auth-exchange","DM_TWAKE_INSTANCE_AUTH_EXCHANGE","auth"],["--twake-instance-b2b-exchange","DM_TWAKE_INSTANCE_B2B_EXCHANGE","b2b"],["--twake-instance-user-created-key","DM_TWAKE_INSTANCE_USER_CREATED_KEY","user.created"],["--twake-instance-organization-created-key","DM_TWAKE_INSTANCE_ORGANIZATION_CREATED_KEY","organization.created"],["--twake-instance-workplace-created-key","DM_TWAKE_INSTANCE_WORKPLACE_CREATED_KEY","workplace.created"],["--twake-instance-queue","DM_TWAKE_INSTANCE_QUEUE","workplace.created.ldap-rest"],["--twake-instance-organization-base","DM_TWAKE_INSTANCE_ORGANIZATION_BASE",""],["--twake-instance-organization-id-attribute","DM_TWAKE_INSTANCE_ORGANIZATION_ID_ATTRIBUTE","ou"],["--twake-instance-organization-domain-attribute","DM_TWAKE_INSTANCE_ORGANIZATION_DOMAIN_ATTRIBUTE","twakeDomain"],["--twake-instance-organization-name-attribute","DM_TWAKE_INSTANCE_ORGANIZATION_NAME_ATTRIBUTE","description"],["--twake-instance-organization-fqdn-attribute","DM_TWAKE_INSTANCE_ORGANIZATION_FQDN_ATTRIBUTE",""],["--rabbitmq-url","DM_RABBITMQ_URL",""],["--applicative-account-base","DM_APPLICATIVE_ACCOUNT_BASE",""],["--max-app-accounts","DM_MAX_APP_ACCOUNTS",5,"number"],["--applicative-account-attribute","DM_APPLICATIVE_ACCOUNT_ATTRIBUTES",["objectClass","cn","sn","givenName","displayName","description"],"array","--applicative-account-attributes","identifiers"],["--ldap-operational-attribute","DM_LDAP_OPERATIONAL_ATTRIBUTES",["dn","controls","structuralObjectClass","entryUUID","entryDN","subschemaSubentry","modifyTimestamp","modifiersName","createTimestamp","creatorsName","userPassword"],"array","--ldap-operational-attributes","identifiers"],["--trash-base","DM_TRASH_BASE",""],["--trash-watched-bases","DM_TRASH_WATCHED_BASES",""],["--trash-add-metadata","DM_TRASH_ADD_METADATA","true"],["--trash-auto-create","DM_TRASH_AUTO_CREATE","true"],["--llng-ini","DM_LLNG_INI","/etc/lemonldap-ng/lemonldap-ng.ini"],["--llng-username-header","DM_LLNG_USERNAME_HEADER",""],["--auth-path-prefix","DM_AUTH_PATH_PREFIX",[],"array","--auth-path-prefixes"],["--auth-token","DM_AUTH_TOKENS",[],"array","--auth-tokens"],["--auth-fake-user","DM_AUTH_FAKE_USER",""],["--auth-totp","DM_AUTH_TOTP",[],"array","--auth-totps","phrases"],["--auth-totp-window","DM_AUTH_TOTP_WINDOW",1,"number"],["--auth-totp-step","DM_AUTH_TOTP_STEP",30,"number"],["--auth-hmac","DM_AUTH_HMAC",[],"array","--auth-hmacs","phrases"],["--auth-hmac-window","DM_AUTH_HMAC_WINDOW",12e4,"number"],["--authz-per-route","DM_AUTHZ_PER_ROUTES",[],"array","--authz-per-routes"],["--authz-unresolved-user","DM_AUTHZ_UNRESOLVED_USER","deny"],["--authz-identity","DM_AUTHZ_IDENTITY","req.user"],["--authz-for","DM_AUTHZ_FOR",[],"array",null,"identifiers"],["--authz-combine","DM_AUTHZ_COMBINE",!1,"boolean"],["--authz-scope-source","DM_AUTHZ_SCOPE_SOURCE",""],["--authz-filter-attached-entries","DM_AUTHZ_FILTER_ATTACHED_ENTRIES",!1,"boolean"],["--authz-transit-branch","DM_AUTHZ_TRANSIT_BRANCH",""],["--authz-transit-origin-attribute","DM_AUTHZ_TRANSIT_ORIGIN_ATTRIBUTE",""],["--authz-per-branch-config","DM_AUTHZ_PER_BRANCH_CONFIG",{default:{read:!0,write:!1,delete:!1}},"json"],["--authz-per-branch-cache-ttl","DM_AUTHZ_PER_BRANCH_CACHE_TTL",60,"number"],["--authz-dynamic-bypass","DM_AUTHZ_DYNAMIC_BYPASS",[],"array",null,"identifiers"],["--authz-dynamic-base","DM_AUTHZ_DYNAMIC_BASE",""],["--authz-dynamic-cache-ttl","DM_AUTHZ_DYNAMIC_CACHE_TTL",60,"number"],["--authz-dynamic-token-attribute","DM_AUTHZ_DYNAMIC_TOKEN_ATTRIBUTE","userPassword"],["--authz-dynamic-config-attribute","DM_AUTHZ_DYNAMIC_CONFIG_ATTRIBUTE","description"],["--authz-dynamic-tenant-attribute","DM_AUTHZ_DYNAMIC_TENANT_ATTRIBUTE","cn"],["--authz-dynamic-reload-endpoint","DM_AUTHZ_DYNAMIC_RELOAD_ENDPOINT",!1,"boolean"],["--authz-local-admin-attribute","DM_AUTHZ_LOCAL_ADMIN_ATTRIBUTE","twakeLocalAdminLink"],["--authz-global-branch","DM_AUTHZ_GLOBAL_BRANCHES",[],"array","--authz-global-branches","dns"],["--authz-global-write-branch","DM_AUTHZ_GLOBAL_WRITE_BRANCHES",[],"array","--authz-global-write-branches","dns"],["--authz-global-organization","DM_AUTHZ_GLOBAL_ORGANIZATION",""],["--oidc-server","DM_OIDC_SERVER",""],["--oidc-username-claim","DM_OIDC_USERNAME_CLAIM","sub"],["--oidc-client-id","DM_OIDC_CLIENT_ID",""],["--oidc-client-secret","DM_OIDC_CLIENT_SECRET",""],["--base-url","DM_BASE_URL",""],["--rate-limit-window-ms","DM_RATE_LIMIT_WINDOW_MS",9e5,"number"],["--rate-limit-max","DM_RATE_LIMIT_MAX",100,"number"],["--crowdsec-url","DM_CROWDSEC_URL","http://localhost:8080/v1/decisions"],["--crowdsec-api-key","DM_CROWDSEC_API_KEY",""],["--crowdsec-cache-ttl","DM_CROWDSEC_CACHE_TTL",60,"number"],["--trusted-proxy","DM_TRUSTED_PROXIES",[],"array","--trusted-proxies","identifiers"],["--trusted-proxy-auth-header","DM_TRUSTED_PROXY_AUTH_HEADER","Auth-User"],["--ppolicy-default-dn","DM_PPOLICY_DEFAULT_DN",""],["--ppolicy-base","DM_PPOLICY_BASE",""],["--ppolicy-warn-days","DM_PPOLICY_WARN_DAYS",14,"number"],["--ppolicy-validate-complexity","DM_PPOLICY_VALIDATE_COMPLEXITY",!1,"boolean"],["--ppolicy-min-length","DM_PPOLICY_MIN_LENGTH",12,"number"],["--ppolicy-require-uppercase","DM_PPOLICY_REQUIRE_UPPERCASE",!0,"boolean"],["--ppolicy-require-lowercase","DM_PPOLICY_REQUIRE_LOWERCASE",!0,"boolean"],["--ppolicy-require-digit","DM_PPOLICY_REQUIRE_DIGIT",!0,"boolean"],["--ppolicy-require-special","DM_PPOLICY_REQUIRE_SPECIAL",!0,"boolean"],["--ldap-users-base","DM_LDAP_USERS_BASE",""],["--scim-prefix","DM_SCIM_PREFIX","/scim/v2"],["--scim-user-base","DM_SCIM_USER_BASE",""],["--scim-group-base","DM_SCIM_GROUP_BASE",""],["--scim-user-base-template","DM_SCIM_USER_BASE_TEMPLATE",""],["--scim-group-base-template","DM_SCIM_GROUP_BASE_TEMPLATE",""],["--scim-base-map","DM_SCIM_BASE_MAP",""],["--scim-user-base-header","DM_SCIM_USER_BASE_HEADER",""],["--scim-group-base-header","DM_SCIM_GROUP_BASE_HEADER",""],["--scim-base-header-root","DM_SCIM_BASE_HEADER_ROOT",""],["--scim-user-object-class","DM_SCIM_USER_OBJECT_CLASSES",["top","inetOrgPerson","organizationalPerson","person"],"array","--scim-user-object-classes","identifiers"],["--scim-user-rdn-attribute","DM_SCIM_USER_RDN_ATTRIBUTE","uid"],["--scim-user-lock-attribute","DM_SCIM_USER_LOCK_ATTRIBUTE","pwdAccountLockedTime"],["--scim-user-lock-value","DM_SCIM_USER_LOCK_VALUE",""],["--scim-group-object-class","DM_SCIM_GROUP_OBJECT_CLASSES",["top","groupOfNames"],"array","--scim-group-object-classes","identifiers"],["--scim-group-rdn-attribute","DM_SCIM_GROUP_RDN_ATTRIBUTE","cn"],["--scim-group-external-id-attribute","DM_SCIM_GROUP_EXTERNAL_ID_ATTRIBUTE",""],["--scim-id-attribute","DM_SCIM_ID_ATTRIBUTE","rdn"],["--scim-user-mapping","DM_SCIM_USER_MAPPING",""],["--scim-group-mapping","DM_SCIM_GROUP_MAPPING",""],["--scim-max-results","DM_SCIM_MAX_RESULTS",200,"number"],["--scim-max-scanned","DM_SCIM_MAX_SCANNED",1e4,"number"],["--scim-bulk-max-operations","DM_SCIM_BULK_MAX_OPERATIONS",100,"number"],["--scim-bulk-max-payload-size","DM_SCIM_BULK_MAX_PAYLOAD_SIZE",1048576,"number"],["--scim-etag","DM_SCIM_ETAG",!1,"boolean"],["--scim-base-url","DM_SCIM_BASE_URL",""]];export{t as default};
@@ -1 +1 @@
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{AmbiguousIdentityError as i,ForbiddenError as n}from"../errors.js";import{isDnInBranch as a,organizationLink as o,getParentDn as h,normalizeDn as c,escapeLdapFilter as u}from"../utils.js";import{authzFor as l,servesRequest as d}from"./composition.js";class AuthzBase extends t{constructor(t){super(t),this.roles=["authz"],this.resolutionCache=new Map,this.resolutionCacheFullWarned=!1,this.fallbackWarned=!1,this.hooks={ldaprenamedone:()=>{this.resolutionCache.clear()},ldapdeletedone:()=>{this.resolutionCache.clear()},ldapmodifyrequest:async([t,e,r,s])=>{if(this.shouldSkipAuthorization(s))return[t,e,r,s];const i=await this.resolveCaller(s);if(!i)return[t,e,r,s];const n=this.config.ldap_organization_link_attribute;if(n&&e.replace?.[n]){let r,a=this.extractBranchDn(t),h=!1;try{const e=(await this.server.ldap.search({paged:!1,scope:"base",attributes:[n]},t)).searchEntries[0];e&&(h=!0,r=o(e[n]),r&&(a=r))}catch{}const c=e.replace[n],u=Array.isArray(c)?String(c[0]):String(c),l=this.filtersAttachedEntries(),d=l&&h&&this.inTransit(r),f=l&&this.inTransit(u);if(!d){const t=f?"write":this.sourcePermission(a);if(!(await this.getUserPermissions(i,a))[t])throw new Error(`[authz-forbidden] User ${s.user} does not have ${t} permission for source branch ${a}`)}if(f){if(d){if(0===(await this.getAuthorizedBranches(i)).length)throw new Error(`[authz-forbidden] User ${s.user} administers no branch`)}}else{if(!(await this.getUserPermissions(i,u)).write)throw new Error(`[authz-forbidden] User ${s.user} does not have write permission for destination branch ${u}`)}}else{const e=await this.effectiveBranch(t);if(!(await this.getUserPermissions(i,e)).write)throw new Error(`[authz-forbidden] User ${s.user} does not have write permission for branch ${e}`)}const a=this.domainLinkAttribute();return a&&await this.guardDomainLinks(i,s,t,[...AuthzBase.valuesOf(e.add,a),...AuthzBase.valuesOf(e.replace,a)],a,!1),[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;const n=this.config.ldap_organization_link_attribute;if(n&&e[n]){const t=e[n];i=Array.isArray(t)?String(t[0]):String(t)}else i=this.extractBranchDn(t);if(!(await this.getUserPermissions(s,i)).write)throw new Error(`[authz-forbidden] User ${r.user} does not have write permission for branch ${i}`);const a=this.domainLinkAttribute();return a&&await this.guardDomainLinks(s,r,t,AuthzBase.valuesOf(e,a),a,!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];if(t===this.config.ldap_top_organization&&"base"===e.scope)return[t,e,r];if(this.filtersAttachedEntries()){if(0===(await this.getAuthorizedBranches(s)).length)throw new Error(`[authz-forbidden] User ${r.user} administers no branch`);if(this.globalBranches().some(e=>a(t,e))&&!(await this.getUserPermissions(s,t)).read)throw new Error(`[authz-forbidden] User ${r.user} does not have read permission for branch ${t}`);const i=this.config.ldap_organization_link_attribute;if(i&&!AuthzBase.wantsAttribute(e,i)){const t=e.attributes,r=Array.isArray(t)?t.map(String):[String(t)];e={...e,attributes:[...r,i]},AuthzBase.widenedForLink.add(e)}return[t,e,r]}if(!(await this.getUserPermissions(s,t)).read)throw new Error(`[authz-forbidden] User ${r.user} does not have read permission for branch ${t}`);return[t,e,r]},ldapsearchfilter:async([t,e,r])=>{const s=[t,e,r];if(!this.filtersAttachedEntries())return s;if(this.shouldSkipAuthorization(e))return s;if(!t?.searchEntries?.length)return s;const i=await this.resolveCaller(e);if(!i)return s;const n=this.globalBranches();if(n.length){const r=new Map,s=[];for(const o of t.searchEntries){const t=n.find(t=>a(o.dn,t));void 0===t||(r.has(t)||r.set(t,!0===(await this.getUserPermissions(i,t)).read),r.get(t))?s.push(o):this.logger.debug(`${this.name}: hiding ${String(o.dn)} from ${e.user}, in the global branch ${t}`)}t.searchEntries=s}const o=this.config.ldap_organization_link_attribute;if(!o)return s;const h=await this.getAuthorizedBranches(i);if(0===h.length)return s;const within=t=>h.some(e=>a(t,e)),c=this.transitKey();if(t.searchEntries=t.searchEntries.filter(t=>{const r=t[o];if(null==r)return!0;const s=(Array.isArray(r)?r:[r]).map(String);return 0===s.length||(!!s.some(within)||(!!s.some(t=>this.inTransit(t,c))||(this.logger.debug(`${this.name}: hiding ${String(t.dn)} from ${e.user}, attached to ${s[0]}`),!1)))}),r&&AuthzBase.widenedForLink.has(r))for(const e of t.searchEntries)delete e[o];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=this.extractBranchDn(t),n=this.extractBranchDn(e),a=await this.getUserPermissions(s,i),o=this.sourcePermission(i);if(!a[o])throw new Error(`[authz-forbidden] User ${r.user} does not have ${o} permission for source branch ${i}`);if(!(await this.getUserPermissions(s,n)).write)throw new Error(`[authz-forbidden] User ${r.user} does not have write permission for destination branch ${n}`);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.effectiveBranch(t);if(!(await this.getUserPermissions(r,s)).delete)throw new Error(`[authz-forbidden] User ${e.user} does not have delete permission for branch ${s}`)}return[t,e]}},r(this.config,this.constructor.name),l(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)}globalBranches(){return[]}sourcePermission(t){return"read"}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.search({paged:!1,scope:"base",attributes:[e]},t),s=o(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&&c(t)===e}transitKey(){const t=this.transitBranch();return void 0===t?void 0:c(t)}async findUserDn(t){const e=`(${this.config.ldap_user_main_attribute||"uid"}=${u(t)})`,r=(await this.server.ldap.search({paged:!1,filter:e,attributes:["dn"],scope:"sub"},this.server.ldap.base)).searchEntries??[];if(r.length>1)throw new i(t,r.length);if(0===r.length)return null;const s=r[0].dn;return"string"==typeof s?s:String(s)}shouldSkipAuthorization(t){return!t?.user||!d(t,this.config)}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,a=Date.now(),o=this.resolutionCache.get(i);let h;if(o&&a-o.at<this.cacheTTL)h=o.user;else{if(h=await this.resolveUser(i),this.resolutionCache.size>=AuthzBase.RESOLUTION_CACHE_MAX)for(const[t,e]of this.resolutionCache)a-e.at>=this.cacheTTL&&this.resolutionCache.delete(t);this.resolutionCache.size<AuthzBase.RESOLUTION_CACHE_MAX?this.resolutionCache.set(i,{user:h,at:a}):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 n(`[authz-forbidden] 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 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.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 o=new Set((n?[]:await this.storedValues(r,i)).map(c)),u=s.filter(t=>!o.has(c(t)));if(0===u.length)return;const l=this.config.ldap_top_organization,d=c(l||this.server.ldap.base),f=new Set;let g=h(r);const w=new Set;for(;g&&!w.has(c(g));){const t=c(g);if(w.add(t),t!==d&&!a(g,d))break;for(const t of await this.storedValues(g,i))f.add(c(t));if(t===d)break;const e=h(g);if(e===g)break;g=e}const m=u.filter(t=>!f.has(c(t)));if(0!==m.length&&!await this.mayLinkAnyDomain(t))throw new Error(`[authz-forbidden] User ${e.user} may not link ${r} 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()}}AuthzBase.widenedForLink=new WeakSet,AuthzBase.UNRESOLVED_POLICIES=["deny","allow"],AuthzBase.RESOLUTION_CACHE_MAX=1e4;export{AuthzBase as default};
1
+ import t from"../../abstract/plugin.js";import{roleAttribute as e}from"../../config/schema.js";import{assertIdentityMode as r,identityFor as i}from"../auth/base.js";import{AmbiguousIdentityError as s,ForbiddenError as n}from"../errors.js";import{isDnInBranch as a,organizationLink as o,getParentDn as h,normalizeDn as c,escapeLdapFilter as u}from"../utils.js";import{authzFor as l,servesRequest as d}from"./composition.js";class AuthzBase extends t{constructor(t){super(t),this.roles=["authz"],this.resolutionCache=new Map,this.resolutionCacheFullWarned=!1,this.fallbackWarned=!1,this.hooks={ldaprenamedone:()=>{this.resolutionCache.clear()},ldapdeletedone:()=>{this.resolutionCache.clear()},ldapmodifyrequest:async([t,e,r,i])=>{if(this.shouldSkipAuthorization(i))return[t,await this.recordTransitOrigin(t,e),r,i];const s=await this.resolveCaller(i);if(!s)return[t,await this.recordTransitOrigin(t,e),r,i];const n=this.config.ldap_organization_link_attribute;if(n&&e.replace?.[n]){let r,a=this.extractBranchDn(t),h=!1;try{const e=(await this.server.ldap.search({paged:!1,scope:"base",attributes:[n]},t)).searchEntries[0];e&&(h=!0,r=o(e[n]),r&&(a=r))}catch{}const c=e.replace[n],u=Array.isArray(c)?String(c[0]):String(c),l=this.filtersAttachedEntries(),d=l&&h&&this.inTransit(r),f=l&&this.inTransit(u);if(!d){const t=f?"write":this.sourcePermission(a);if(!(await this.getUserPermissions(s,a))[t])throw new Error(`[authz-forbidden] User ${i.user} does not have ${t} permission for source branch ${a}`)}if(f){if(d){if(0===(await this.getAuthorizedBranches(s)).length)throw new Error(`[authz-forbidden] User ${i.user} administers no branch`)}}else{if(!(await this.getUserPermissions(s,u)).write)throw new Error(`[authz-forbidden] User ${i.user} does not have write permission for destination branch ${u}`)}}else{const e=await this.effectiveBranch(t);if(!(await this.getUserPermissions(s,e)).write)throw new Error(`[authz-forbidden] User ${i.user} does not have write permission for branch ${e}`)}const a=this.domainLinkAttribute();return a&&await this.guardDomainLinks(s,i,t,[...AuthzBase.valuesOf(e.add,a),...AuthzBase.valuesOf(e.replace,a)],a,!1),[t,await this.recordTransitOrigin(t,e),r,i]},ldapaddrequest:async([t,e,r])=>{if(this.shouldSkipAuthorization(r))return[t,e,r];const i=await this.resolveCaller(r);if(!i)return[t,e,r];let s;const n=this.config.ldap_organization_link_attribute;if(n&&e[n]){const t=e[n];s=Array.isArray(t)?String(t[0]):String(t)}else s=this.extractBranchDn(t);if(!(await this.getUserPermissions(i,s)).write)throw new Error(`[authz-forbidden] User ${r.user} does not have write permission for branch ${s}`);const a=this.domainLinkAttribute();return a&&await this.guardDomainLinks(i,r,t,AuthzBase.valuesOf(e,a),a,!0),[t,e,r]},ldapsearchrequest:async([t,e,r])=>{if(this.shouldSkipAuthorization(r))return[t,e,r];const i=await this.resolveCaller(r);if(!i)return[t,e,r];if(t===this.config.ldap_top_organization&&"base"===e.scope)return[t,e,r];const s=await this.searchRefusal(i,t);if(s)throw new Error(`[authz-forbidden] User ${r.user} ${s}`);if(this.filtersAttachedEntries()){const i=this.config.ldap_organization_link_attribute;if(i&&!AuthzBase.wantsAttribute(e,i)){const t=e.attributes,r=Array.isArray(t)?t.map(String):[String(t)];e={...e,attributes:[...r,i]},AuthzBase.widenedForLink.add(e)}return[t,e,r]}return[t,e,r]},ldapsearchfilter:async([t,e,r])=>{const i=[t,e,r];if(!this.filtersAttachedEntries())return i;if(this.shouldSkipAuthorization(e))return i;if(!t?.searchEntries?.length)return i;const s=await this.resolveCaller(e);if(!s)return i;const n=this.globalBranches();if(n.length){const r=new Map,i=[];for(const o of t.searchEntries){const t=n.find(t=>a(o.dn,t));void 0===t||(r.has(t)||r.set(t,!0===(await this.getUserPermissions(s,t)).read),r.get(t))?i.push(o):this.logger.debug(`${this.name}: hiding ${String(o.dn)} from ${e.user}, in the global branch ${t}`)}t.searchEntries=i}const o=this.config.ldap_organization_link_attribute;if(!o)return i;const h=await this.getAuthorizedBranches(s);if(0===h.length)return i;const within=t=>h.some(e=>a(t,e)),c=this.transitKey();if(t.searchEntries=t.searchEntries.filter(t=>{const r=t[o];if(null==r)return!0;const i=(Array.isArray(r)?r:[r]).map(String);return 0===i.length||(!!i.some(within)||(!!i.some(t=>this.inTransit(t,c))||(this.logger.debug(`${this.name}: hiding ${String(t.dn)} from ${e.user}, attached to ${i[0]}`),!1)))}),r&&AuthzBase.widenedForLink.has(r))for(const e of t.searchEntries)delete e[o];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 i=await this.getAuthorizedBranches(r);if(i.length>0){const e=[];for(const r of i)try{const i=await this.server.ldap.search({paged:!1,scope:"base"},r,t);1===i.searchEntries.length&&e.push(i.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 i=await this.resolveCaller(r);if(!i)return[t,e,r];const s=this.extractBranchDn(t),n=this.extractBranchDn(e),a=await this.getUserPermissions(i,s),o=this.sourcePermission(s);if(!a[o])throw new Error(`[authz-forbidden] User ${r.user} does not have ${o} permission for source branch ${s}`);if(!(await this.getUserPermissions(i,n)).write)throw new Error(`[authz-forbidden] User ${r.user} does not have write permission for destination branch ${n}`);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 i=Array.isArray(t)?t:[t];for(const t of i){const i=await this.effectiveBranch(t);if(!(await this.getUserPermissions(r,i)).delete)throw new Error(`[authz-forbidden] User ${e.user} does not have delete permission for branch ${i}`)}return[t,e]}},r(this.config,this.constructor.name),l(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 i=(Array.isArray(r)?r:[r]).map(t=>String(t).toLowerCase());return 0===i.length||(!!i.includes("*")||i.includes(e.toLowerCase()))}extractBranchDn(t){return h(t)}filtersAttachedEntries(){return Boolean(this.config.authz_filter_attached_entries)}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=>a(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 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.search({paged:!1,scope:"base",attributes:[e]},t),i=o(r.searchEntries?.[0]?.[e]);if(i)return i}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&&c(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(),i=this.config.ldap_organization_link_attribute;if(!r||!i)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 s,n;if(named(e.replace,i))s=o(AuthzBase.valuesOf(e.replace,i));else if(named(e.add,i))s=o(AuthzBase.valuesOf(e.add,i));else if(!deleted(i))return e;if(named(e.replace,r)||named(e.add,r)||deleted(r))return e;try{n=(await this.server.ldap.search({paged:!1,scope:"base",attributes:[i,r]},t)).searchEntries[0]}catch{}if(!n)return e;const a=o(AuthzBase.valuesOf(n,i)),h=this.inTransit(a),c=this.inTransit(s);return!h&&c&&a?{...e,replace:{...e.replace,[r]:a}}:h&&!c&&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:c(t)}async findUserDn(t){const e=`(${this.config.ldap_user_main_attribute||"uid"}=${u(t)})`,r=(await this.server.ldap.search({paged:!1,filter:e,attributes:["dn"],scope:"sub"},this.server.ldap.base)).searchEntries??[];if(r.length>1)throw new s(t,r.length);if(0===r.length)return null;const i=r[0].dn;return"string"==typeof i?i:String(i)}shouldSkipAuthorization(t){return!t?.user||!d(t,this.config)}async resolveCaller(t){const{value:e,fellBack:r}=i(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 s=e,a=Date.now(),o=this.resolutionCache.get(s);let h;if(o&&a-o.at<this.cacheTTL)h=o.user;else{if(h=await this.resolveUser(s),this.resolutionCache.size>=AuthzBase.RESOLUTION_CACHE_MAX)for(const[t,e]of this.resolutionCache)a-e.at>=this.cacheTTL&&this.resolutionCache.delete(t);this.resolutionCache.size<AuthzBase.RESOLUTION_CACHE_MAX?this.resolutionCache.set(s,{user:h,at:a}):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 ${s} could not be resolved by ${this.name}; allowed by --authz-unresolved-user allow`),null;throw new n(`[authz-forbidden] User ${s} 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 valuesOf(t,e){if(!t)return[];const r=e.toLowerCase(),i=[];for(const[e,s]of Object.entries(t))if(e.split(";")[0].toLowerCase()===r&&null!=s)for(const t of Array.isArray(s)?s:[s])i.push(String(t));return i}async storedValues(t,e){try{const r=await this.server.ldap.search({paged:!1,scope:"base",attributes:[e]},t);return AuthzBase.valuesOf(r.searchEntries?.[0],e)}catch{return[]}}async guardDomainLinks(t,e,r,i,s,n){if(0===i.length)return;const o=new Set((n?[]:await this.storedValues(r,s)).map(c)),u=i.filter(t=>!o.has(c(t)));if(0===u.length)return;const l=this.config.ldap_top_organization,d=c(l||this.server.ldap.base),f=new Set;let g=h(r);const w=new Set;for(;g&&!w.has(c(g));){const t=c(g);if(w.add(t),t!==d&&!a(g,d))break;for(const t of await this.storedValues(g,s))f.add(c(t));if(t===d)break;const e=h(g);if(e===g)break;g=e}const p=u.filter(t=>!f.has(c(t)));if(0!==p.length&&!await this.mayLinkAnyDomain(t))throw new Error(`[authz-forbidden] 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()}}AuthzBase.widenedForLink=new WeakSet,AuthzBase.UNRESOLVED_POLICIES=["deny","allow"],AuthzBase.RESOLUTION_CACHE_MAX=1e4;export{AuthzBase as default};
@@ -1 +1 @@
1
- class HttpError extends Error{constructor(r,o=500){super(r),this.statusCode=o,this.name="HttpError"}}class BadRequestError extends HttpError{constructor(r="Bad request"){super(r,400),this.name="BadRequestError"}}class UnauthorizedError extends HttpError{constructor(r="Unauthorized"){super(r,401),this.name="UnauthorizedError"}}const r="Token does not have permission on this branch";class ForbiddenError extends HttpError{constructor(r="Forbidden"){super(r,403),this.name="ForbiddenError"}}class AmbiguousIdentityError extends ForbiddenError{constructor(r,o){super(`[authz-forbidden] ${r} names ${o} directory entries, so no single one can be taken for the caller`),this.identity=r,this.count=o,this.name="AmbiguousIdentityError"}}class NotFoundError extends HttpError{constructor(r="Not found"){super(r,404),this.name="NotFoundError"}}class ConflictError extends HttpError{constructor(r="Conflict"){super(r,409),this.name="ConflictError"}}class UriTooLongError extends HttpError{constructor(r="URI Too Long"){super(r,414),this.name="UriTooLongError"}}class UnprocessableEntityError extends HttpError{constructor(r="Unprocessable Entity"){super(r,422),this.name="UnprocessableEntityError"}}class TooManyRequestsError extends HttpError{constructor(r="Too Many Requests"){super(r,429),this.name="TooManyRequestsError"}}class BadGatewayError extends HttpError{constructor(r="Bad Gateway"){super(r,502),this.name="BadGatewayError"}}class ServiceUnavailableError extends HttpError{constructor(r="Service Unavailable"){super(r,503),this.name="ServiceUnavailableError"}}class GatewayTimeoutError extends HttpError{constructor(r="Gateway Timeout"){super(r,504),this.name="GatewayTimeoutError"}}export{r as AUTHZ_REFUSED,AmbiguousIdentityError,BadGatewayError,BadRequestError,ConflictError,ForbiddenError,GatewayTimeoutError,HttpError,NotFoundError,ServiceUnavailableError,TooManyRequestsError,UnauthorizedError,UnprocessableEntityError,UriTooLongError};
1
+ class HttpError extends Error{constructor(r,o=500,t){super(r),this.statusCode=o,this.refusal=t,this.name="HttpError"}}class BadRequestError extends HttpError{constructor(r="Bad request"){super(r,400),this.name="BadRequestError"}}class UnauthorizedError extends HttpError{constructor(r="Unauthorized"){super(r,401),this.name="UnauthorizedError"}}const r="Token does not have permission on this branch",errorBody=(o,t)=>{if(t===r)return{error:t,code:"forbidden"};const e=o instanceof HttpError?o.refusal:void 0;return e?{error:t,...e}:{error:t}};class ForbiddenError extends HttpError{constructor(r="Forbidden"){super(r,403),this.name="ForbiddenError"}}class AmbiguousIdentityError extends ForbiddenError{constructor(r,o){super(`[authz-forbidden] ${r} names ${o} directory entries, so no single one can be taken for the caller`),this.identity=r,this.count=o,this.name="AmbiguousIdentityError"}}class NotFoundError extends HttpError{constructor(r="Not found"){super(r,404),this.name="NotFoundError"}}class ConflictError extends HttpError{constructor(r="Conflict",o){super(r,409,o),this.name="ConflictError"}}class UriTooLongError extends HttpError{constructor(r="URI Too Long"){super(r,414),this.name="UriTooLongError"}}class UnprocessableEntityError extends HttpError{constructor(r="Unprocessable Entity"){super(r,422),this.name="UnprocessableEntityError"}}class TooManyRequestsError extends HttpError{constructor(r="Too Many Requests"){super(r,429),this.name="TooManyRequestsError"}}class BadGatewayError extends HttpError{constructor(r="Bad Gateway"){super(r,502),this.name="BadGatewayError"}}class ServiceUnavailableError extends HttpError{constructor(r="Service Unavailable"){super(r,503),this.name="ServiceUnavailableError"}}class GatewayTimeoutError extends HttpError{constructor(r="Gateway Timeout"){super(r,504),this.name="GatewayTimeoutError"}}export{r as AUTHZ_REFUSED,AmbiguousIdentityError,BadGatewayError,BadRequestError,ConflictError,ForbiddenError,GatewayTimeoutError,HttpError,NotFoundError,ServiceUnavailableError,TooManyRequestsError,UnauthorizedError,UnprocessableEntityError,UriTooLongError,errorBody};
@@ -1 +1 @@
1
- import{AUTHZ_REFUSED as r}from"./errors.js";let t;const setLogger=r=>{t=r},getLogger=()=>t,tryMethod=async(r,t,...e)=>{try{return await t(...e),ok(r)}catch(t){return serverError(r,t)}},tryMethodData=async(r,t,...e)=>{try{const n=await t(...e);return ok(r,n)}catch(t){return serverError(r,t)}},_rejectResponse=(r,e,n)=>{t?.warn(`Client error ${r}: ${n}`),e.status(r).json({error:n})},ok=(r,t={success:!0})=>{r.status(200).json(t)},created=(r,t)=>{r.status(201).json(t)},multiStatus=(r,t)=>{r.status(207).json(t)},badRequest=(r,t="Bad request")=>_rejectResponse(400,r,t),unauthorized=(r,t="Unauthorized")=>_rejectResponse(401,r,t),forbidden=(r,t="Forbidden")=>_rejectResponse(403,r,t),notFound=(r,t="Not found")=>_rejectResponse(404,r,t),serverError=(e,n)=>{const o=n instanceof Error?n.message:String(n);let s=n instanceof Error&&"statusCode"in n?n.statusCode:500;if(500===s&&/\[authz-forbidden\]/.test(o)&&(s=403),s>=400&&s<500){const n=403===s&&/\[authz-forbidden\]/.test(o)?r:o;return t?.warn(`Client error ${s}: ${n}`),void e.status(s).json({error:n})}n instanceof Error?t?.error(n.message,n):"string"==typeof n?t?.error(n):t?.error("Server error",n),e.status(s).json({error:"check logs"})},wantJson=(r,t)=>!1!==r.accepts("json")||(badRequest(t),!1),jsonBody=(r,t,...e)=>{try{if(!wantJson(r,t))return!1;const n=r.body;if(null===n||"object"!=typeof n||Array.isArray(n))return badRequest(t,"Bad content"),!1;if(e.length>0)for(let r=0;r<e.length;r++)if(null==n[e[r]])return badRequest(t,"Bad content"),!1;return n}catch(r){return badRequest(t,"Bad content"),!1}};export{badRequest,created,forbidden,getLogger,jsonBody,multiStatus,notFound,ok,serverError,setLogger,tryMethod,tryMethodData,unauthorized,wantJson};
1
+ import{errorBody as r,AUTHZ_REFUSED as t}from"./errors.js";let e;const setLogger=r=>{e=r},getLogger=()=>e,tryMethod=async(r,t,...e)=>{try{return await t(...e),ok(r)}catch(t){return serverError(r,t)}},tryMethodData=async(r,t,...e)=>{try{const n=await t(...e);return ok(r,n)}catch(t){return serverError(r,t)}},_rejectResponse=(r,t,n)=>{e?.warn(`Client error ${r}: ${n}`),t.status(r).json({error:n})},ok=(r,t={success:!0})=>{r.status(200).json(t)},created=(r,t)=>{r.status(201).json(t)},multiStatus=(r,t)=>{r.status(207).json(t)},badRequest=(r,t="Bad request")=>_rejectResponse(400,r,t),unauthorized=(r,t="Unauthorized")=>_rejectResponse(401,r,t),forbidden=(r,t="Forbidden")=>_rejectResponse(403,r,t),notFound=(r,t="Not found")=>_rejectResponse(404,r,t),serverError=(n,s)=>{const o=s instanceof Error?s.message:String(s);let a=s instanceof Error&&"statusCode"in s?s.statusCode:500;if(500===a&&/\[authz-forbidden\]/.test(o)&&(a=403),a>=400&&a<500){const c=403===a&&/\[authz-forbidden\]/.test(o)?t:o;return e?.warn(`Client error ${a}: ${c}`),void n.status(a).json(r(s,c))}s instanceof Error?e?.error(s.message,s):"string"==typeof s?e?.error(s):e?.error("Server error",s),n.status(a).json({error:"check logs"})},wantJson=(r,t)=>!1!==r.accepts("json")||(badRequest(t),!1),jsonBody=(r,t,...e)=>{try{if(!wantJson(r,t))return!1;const n=r.body;if(null===n||"object"!=typeof n||Array.isArray(n))return badRequest(t,"Bad content"),!1;if(e.length>0)for(let r=0;r<e.length;r++)if(null==n[e[r]])return badRequest(t,"Bad content"),!1;return n}catch(r){return badRequest(t,"Bad content"),!1}};export{badRequest,created,forbidden,getLogger,jsonBody,multiStatus,notFound,ok,serverError,setLogger,tryMethod,tryMethodData,unauthorized,wantJson};
@@ -1 +1 @@
1
- import e from"../../abstract/plugin.js";import{identityFor as s}from"../../lib/auth/base.js";import{servesRequest as t,overlap as n,EVERYONE as i,authzFor as a}from"../../lib/authz/composition.js";import{asyncHandler as r}from"../../lib/utils.js";import{UnauthorizedError as o}from"../../lib/errors.js";import{roleAttribute as c}from"../../config/schema.js";class AuthzScope extends e{constructor(){super(...arguments),this.name="authzScope",this.roles=["api"]}api(e){e.get(`${this.config.api_prefix}/v1/authz/scope`,r(async(e,s)=>this.scope(e,s)))}static canDescribe(e){if(!e.roles?.includes("authz"))return!1;const s=e;return"function"==typeof s.resolveUser&&"function"==typeof s.getAuthorizedBranches&&"function"==typeof s.getUserPermissions}describers(){return Object.values(this.server.loadedPlugins).filter(e=>AuthzScope.canDescribe(e))}static judges(e,s){return e.roles?.includes("auth")?Boolean(s.authenticators?.includes(e.name)):Boolean(s.user)&&t(s,e.config)}assertComposition(){const e=this.config.authz_scope_source;if(!e)return;const s=this.describers().map(e=>e.name);if(!s.includes(e))throw new Error(`${this.name}: --authz-scope-source names ${e}, which is not a loaded plugin able to describe a scope`+(s.length>0?` (loaded: ${s.join(", ")})`:""))}afterLoad(){if(this.config.authz_scope_source)return;const e=this.describers(),s=this.server.authenticators,population=e=>a(e.config,e.name)??i;for(let t=0;t<e.length;t++)for(let i=t+1;i<e.length;i++){const a=e[t],r=e[i];if(n(population(a),population(r),s))return void this.logger.warn(`${this.name}: ${a.name} and ${r.name} can both describe the scope of one caller, and ${a.name} answers because it loaded first. Set --authz-scope-source to choose; the answer lists both under \`sources\``)}}entities(){const e=(this.config.ldap_organization_link_attribute||"").toLowerCase(),linkable=s=>Boolean(e)&&(!s?.attributes||Object.keys(s.attributes).some(s=>s.toLowerCase()===e)),s=[],t=this.server.loadedPlugins.ldapFlatGeneric;for(const e of t?.instances||[])s.push({name:e.pluralName,base:e.base,linkable:linkable(e.schema)});const n=this.server.loadedPlugins.ldapGroups;return n?.base&&s.push({name:"groups",base:n.base,linkable:linkable(n.schema)}),this.server.loadedPlugins.ldapOrganizations&&this.config.ldap_top_organization&&s.push({name:"organizations",base:this.config.ldap_top_organization,linkable:!1}),s}async scope(e,t){const n=this.describers();if(Object.values(this.server.loadedPlugins).some(e=>e.roles?.includes("authz"))&&!e.user)throw new o("No authenticated user");const i=Object.values(this.server.loadedPlugins).filter(s=>s.roles?.includes("authz")&&AuthzScope.judges(s,e)),a=n.filter(e=>i.includes(e)),r=this.config.authz_scope_source,c=a.find(e=>e.name===r)??a[0],u=i.map(e=>e.name);if(!c)return 0===i.length?void t.json({user:e.user??null,unrestricted:!0,described:!0,source:null,sources:[],branches:[],linksAnyDomain:!0,entities:this.entities().map(({name:e,base:s})=>({name:e,base:s,create:!0}))}):void t.json({user:e.user??null,unrestricted:!1,described:!1,source:null,sources:u,branches:[],linksAnyDomain:!1,entities:[]});const l=s(e,c.config).value,h=await c.resolveUser(l);if(!h)throw new o(`Unknown user ${l}`);const d=await c.getAuthorizedBranches(h),p=[],f=new Map,permissionsOn=async e=>{let s=f.get(e);return s||(s=await c.getUserPermissions(h,e),f.set(e,s)),s};for(const e of d){const s=await permissionsOn(e);p.push({dn:e,...s,...await this.branchLabel(e)})}const m=d.some(e=>!0===f.get(e)?.write),b=this.config.ldap_top_organization,g=[];for(const{name:e,base:s,linkable:t}of this.entities()){const n=b&&s===b?!0===(await permissionsOn(b)).write:t?m:!0===(await permissionsOn(s)).write;g.push({name:e,base:s,create:n})}t.json({user:h,unrestricted:!1,described:!0,source:c.name,sources:u,branches:p,transit:c.transitBranch?.()??null,linksAnyDomain:!0===await(c.mayLinkAnyDomain?.(h)),entities:g})}async branchLabel(e){const s=this.server.loadedPlugins.ldapOrganizations,t=c(s?.schema,"organizationPath")||this.config.ldap_organization_path_attribute;try{const s=await this.server.ldap.search({paged:!1,scope:"base",attributes:["ou","o","cn",...t?[t]:[]]},e),n=s?.searchEntries?.[0];if(!n)return{};const first=e=>{const s=Array.isArray(e)?e[0]:e;return"string"==typeof s?s||void 0:Buffer.isBuffer(s)&&s.toString("utf8")||void 0};return{name:first(n.ou)||first(n.o)||first(n.cn),path:t?first(n[t]):void 0}}catch(e){return{}}}}export{AuthzScope as default};
1
+ import e from"../../abstract/plugin.js";import{identityFor as s}from"../../lib/auth/base.js";import{servesRequest as t,overlap as i,EVERYONE as a,authzFor as r}from"../../lib/authz/composition.js";import{asyncHandler as o}from"../../lib/utils.js";import{UnauthorizedError as n}from"../../lib/errors.js";import{roleAttribute as c}from"../../config/schema.js";class AuthzScope extends e{constructor(){super(...arguments),this.name="authzScope",this.roles=["api"]}api(e){e.get(`${this.config.api_prefix}/v1/authz/scope`,o(async(e,s)=>this.scope(e,s)))}static canDescribe(e){if(!e.roles?.includes("authz"))return!1;const s=e;return"function"==typeof s.resolveUser&&"function"==typeof s.getAuthorizedBranches&&"function"==typeof s.getUserPermissions}describers(){return Object.values(this.server.loadedPlugins).filter(e=>AuthzScope.canDescribe(e))}static judges(e,s){return e.roles?.includes("auth")?Boolean(s.authenticators?.includes(e.name)):Boolean(s.user)&&t(s,e.config)}assertComposition(){const e=this.config.authz_scope_source;if(!e)return;const s=this.describers().map(e=>e.name);if(!s.includes(e))throw new Error(`${this.name}: --authz-scope-source names ${e}, which is not a loaded plugin able to describe a scope`+(s.length>0?` (loaded: ${s.join(", ")})`:""))}afterLoad(){if(this.config.authz_scope_source)return;const e=this.describers(),s=this.server.authenticators,population=e=>r(e.config,e.name)??a;for(let t=0;t<e.length;t++)for(let a=t+1;a<e.length;a++){const r=e[t],o=e[a];if(i(population(r),population(o),s))return void this.logger.warn(`${this.name}: ${r.name} and ${o.name} can both describe the scope of one caller, and ${r.name} answers because it loaded first. Set --authz-scope-source to choose; the answer lists both under \`sources\``)}}entities(){const e=(this.config.ldap_organization_link_attribute||"").toLowerCase(),linkable=s=>Boolean(e)&&(!s?.attributes||Object.keys(s.attributes).some(s=>s.toLowerCase()===e)),s=[],t=this.server.loadedPlugins.ldapFlatGeneric;for(const e of t?.instances||[])s.push({name:e.pluralName,base:e.base,linkable:linkable(e.schema)});const i=this.server.loadedPlugins.ldapGroups;return i?.base&&s.push({name:"groups",base:i.base,linkable:linkable(i.schema)}),this.server.loadedPlugins.ldapOrganizations&&this.config.ldap_top_organization&&s.push({name:"organizations",base:this.config.ldap_top_organization,linkable:!1}),s}async scope(e,t){const i=this.describers();if(Object.values(this.server.loadedPlugins).some(e=>e.roles?.includes("authz"))&&!e.user)throw new n("No authenticated user");const a=Object.values(this.server.loadedPlugins).filter(s=>s.roles?.includes("authz")&&AuthzScope.judges(s,e)),r=i.filter(e=>a.includes(e)),o=this.config.authz_scope_source,c=r.find(e=>e.name===o)??r[0],u=a.map(e=>e.name);if(!c)return 0===a.length?void t.json({user:e.user??null,unrestricted:!0,described:!0,source:null,sources:[],branches:[],linksAnyDomain:!0,...this.passwordPolicies(!0),entities:this.entities().map(({name:e,base:s})=>({name:e,base:s,create:!0}))}):void t.json({user:e.user??null,unrestricted:!1,described:!1,source:null,sources:u,branches:[],linksAnyDomain:!1,...this.passwordPolicies(!1),entities:[]});const l=s(e,c.config).value,h=await c.resolveUser(l);if(!h)throw new n(`Unknown user ${l}`);const d=await c.getAuthorizedBranches(h),p=[],f=new Map,permissionsOn=async e=>{let s=f.get(e);return s||(s=await c.getUserPermissions(h,e),f.set(e,s)),s};for(const e of d){const s=await permissionsOn(e);p.push({dn:e,...s,...await this.branchLabel(e)})}const g=d.some(e=>!0===f.get(e)?.write),m=this.config.ldap_top_organization,b=[];for(const{name:e,base:s,linkable:t}of this.entities()){const i=m&&s===m?!0===(await permissionsOn(m)).write:t?g:!0===(await permissionsOn(s)).write;b.push({name:e,base:s,create:i})}t.json({user:h,unrestricted:!1,described:!0,source:c.name,sources:u,branches:p,transit:c.transitBranch?.()??null,linksAnyDomain:!0===await(c.mayLinkAnyDomain?.(h)),...this.passwordPolicies(await this.readsPolicies(c,h)),entities:b})}policyPlugin(){return this.server.loadedPlugins.ldapPasswordPolicy}passwordPolicies(e){return this.policyPlugin()?{passwordPolicies:e}:{}}async readsPolicies(e,s){const t=this.policyPlugin();return!(!t||!e.searchRefusal)&&void 0===await e.searchRefusal(s,t.policyBase())}async branchLabel(e){const s=this.server.loadedPlugins.ldapOrganizations,t=c(s?.schema,"organizationPath")||this.config.ldap_organization_path_attribute;try{const s=await this.server.ldap.search({paged:!1,scope:"base",attributes:["ou","o","cn",...t?[t]:[]]},e),i=s?.searchEntries?.[0];if(!i)return{};const first=e=>{const s=Array.isArray(e)?e[0]:e;return"string"==typeof s?s||void 0:Buffer.isBuffer(s)&&s.toString("utf8")||void 0};return{name:first(i.ou)||first(i.o)||first(i.cn),path:t?first(i[t]):void 0}}catch(e){return{}}}}export{AuthzScope as default};
@@ -1 +1 @@
1
- import t from"multer";import{parse as e}from"csv-parse/sync";import i from"../../abstract/plugin.js";import{badRequest as r,serverError as s}from"../../lib/expressFormatedResponses.js";import{loadSchemaFile as o}from"../../lib/schemaFile.js";import{validateDnValue as n,escapeDnValue as a}from"../../lib/utils.js";const c=/^([A-Za-z][A-Za-z0-9-]*|\d+(\.\d+)+)(;[A-Za-z0-9-]+)*$/;class LdapBulkImport extends i{constructor(e){super(e),this.name="ldapBulkImport",this.roles=["configurable"],this.resources=new Map;const i=parseInt(this.config.bulk_import_max_file_size,10)||10485760;this.upload=t({storage:t.memoryStorage(),limits:{fileSize:i},fileFilter:(t,e,i)=>{"text/csv"===e.mimetype||e.originalname.endsWith(".csv")?i(null,!0):i(new Error("Only CSV files are allowed"))}});const r=this.config.bulk_import_schemas;if(!r)return void this.logger.warn("No bulk import schemas configured");const s=r.split(",").map(t=>t.trim());for(const t of s){const[e,i]=t.split(":").map(t=>t.trim());if(e&&i)try{const t=o(i,{config:this.config});this.resources.set(e,{name:e,schema:t,base:t.base||this.config.ldap_base,mainAttribute:t.mainAttribute||"cn"}),this.logger.info(`Loaded bulk import resource: ${e} from ${i}`)}catch(t){this.logger.error(`Failed to load schema for ${e}: ${t.message}`)}else this.logger.warn(`Invalid schema entry: ${t}`)}}api(t){for(const[e,i]of this.resources)t.get(`${this.config.api_prefix}/v1/ldap/bulk-import/${e}/template.csv`,(t,e)=>this.getTemplate(t,e,i)),t.post(`${this.config.api_prefix}/v1/ldap/bulk-import/${e}`,this.upload.single("file"),async(t,e)=>this.bulkImport(t,e,i))}getTemplate(t,e,i){const r=this.getEditableAttributes(i.schema);r.push("organizationDn"),e.setHeader("Content-Type","text/csv; charset=utf-8"),e.setHeader("Content-Disposition",`attachment; filename="${i.name}-template.csv"`),e.send(r.join(",")+"\n")}async bulkImport(t,i,o){if(!t.file)return r(i,"No file uploaded");const n="true"===t.body.dryRun||!0===t.body.dryRun,a="true"===t.body.updateExisting||!0===t.body.updateExisting,c="false"!==t.body.continueOnError&&!1!==t.body.continueOnError,l=Date.now(),p={success:!0,total:0,created:0,updated:0,skipped:0,failed:0,errors:[],details:{duration:"",linesProcessed:0}};try{const r=t.file.buffer.toString("utf-8"),s=e(r,{columns:!0,skip_empty_lines:!0,trim:!0});p.total=s.length;for(let e=0;e<s.length;e++){const i=e+2,r=s[e];try{const{dn:e,entry:i}=await this.processRecord(r,o),s=await this.entryExists(e);if(s&&!a){p.skipped++;continue}if(n){p.created++;continue}s&&a?(await this.server.ldap.modify(e,{replace:i},t),p.updated++):(await this.server.ldap.add(e,i,t),p.created++)}catch(t){if(p.failed++,p.errors.push({line:i,identifier:r[o.mainAttribute],error:t.message}),!c)throw t}}p.details.linesProcessed=s.length,p.details.duration=`${((Date.now()-l)/1e3).toFixed(1)}s`,i.json(p)}catch(t){return s(i,t)}}async processRecord(t,e){const i={},r=e.schema,s=t.organizationDn;delete t.organizationDn;for(const[t,e]of Object.entries(r.properties||{}))if(!0===e.fixed){const r=e.default||e.const;void 0!==r&&(i[t]=r)}for(const[e,r]of Object.entries(t))if(r&&""!==r.trim()){if(!c.test(e))throw new Error(`Invalid column name: ${e}`);i[e]=r.includes(";")?r.split(";").map(t=>t.trim()):r}if(s){const t=this.findAttributeByRole(r,"organizationLink");t&&(i[t]=s);const e=this.findAttributeByRole(r,"organizationPath");if(e){const t=(await this.fetchOrganization(s))[e];i[e]=Array.isArray(t)?t[0]:t}}for(const[t,e]of Object.entries(r.properties||{}))if(!0===e.required&&!i[t])throw new Error(`Missing required attribute: ${t}`);const o=e.mainAttribute,l=i[o];if(!l)throw new Error(`Missing main attribute: ${o}`);const p=Array.isArray(l)?String(l[0]):String(l);n(p,o);return{dn:`${o}=${a(p)},${e.base}`,entry:i}}getEditableAttributes(t){const e=[];for(const[i,r]of Object.entries(t.properties||{}))!0!==r.fixed&&(r.role?.includes("organizationLink")||r.role?.includes("organizationPath")||e.push(i));return e}findAttributeByRole(t,e){for(const[i,r]of Object.entries(t.properties||{}))if(r.role?.includes(e))return i;return null}async fetchOrganization(t){const e=await this.server.ldap.search({paged:!1,scope:"base"},t);if(0===e.searchEntries.length)throw new Error(`Organization not found: ${t}`);return e.searchEntries[0]}async entryExists(t){try{return(await this.server.ldap.search({paged:!1,scope:"base"},t)).searchEntries.length>0}catch(t){return!1}}getConfigApiData(){const t=this.config.api_prefix||"/api",e=[];return this.resources.forEach((i,r)=>{e.push({name:r,mainAttribute:i.mainAttribute,base:i.base,maxFileSize:parseInt(this.config.bulk_import_max_file_size,10)||10485760,batchSize:parseInt(this.config.bulk_import_batch_size,10)||100,endpoints:{template:`${t}/v1/ldap/bulk-import/${r}/template.csv`,import:`${t}/v1/ldap/bulk-import/${r}`}})}),{enabled:!0,resources:e}}}export{LdapBulkImport as default};
1
+ import t from"multer";import{parse as e}from"csv-parse/sync";import i from"../../abstract/plugin.js";import{badRequest as r,serverError as s}from"../../lib/expressFormatedResponses.js";import{HttpError as o}from"../../lib/errors.js";import{loadSchemaFile as n}from"../../lib/schemaFile.js";import{validateDnValue as a,escapeDnValue as c}from"../../lib/utils.js";const l=/^([A-Za-z][A-Za-z0-9-]*|\d+(\.\d+)+)(;[A-Za-z0-9-]+)*$/;class LdapBulkImport extends i{constructor(e){super(e),this.name="ldapBulkImport",this.roles=["configurable"],this.resources=new Map;const i=parseInt(this.config.bulk_import_max_file_size,10)||10485760;this.upload=t({storage:t.memoryStorage(),limits:{fileSize:i},fileFilter:(t,e,i)=>{"text/csv"===e.mimetype||e.originalname.endsWith(".csv")?i(null,!0):i(new Error("Only CSV files are allowed"))}});const r=this.config.bulk_import_schemas;if(!r)return void this.logger.warn("No bulk import schemas configured");const s=r.split(",").map(t=>t.trim());for(const t of s){const[e,i]=t.split(":").map(t=>t.trim());if(e&&i)try{const t=n(i,{config:this.config});this.resources.set(e,{name:e,schema:t,base:t.base||this.config.ldap_base,mainAttribute:t.mainAttribute||"cn"}),this.logger.info(`Loaded bulk import resource: ${e} from ${i}`)}catch(t){this.logger.error(`Failed to load schema for ${e}: ${t.message}`)}else this.logger.warn(`Invalid schema entry: ${t}`)}}api(t){for(const[e,i]of this.resources)t.get(`${this.config.api_prefix}/v1/ldap/bulk-import/${e}/template.csv`,(t,e)=>this.getTemplate(t,e,i)),t.post(`${this.config.api_prefix}/v1/ldap/bulk-import/${e}`,this.upload.single("file"),async(t,e)=>this.bulkImport(t,e,i))}getTemplate(t,e,i){const r=this.getEditableAttributes(i.schema);r.push("organizationDn"),e.setHeader("Content-Type","text/csv; charset=utf-8"),e.setHeader("Content-Disposition",`attachment; filename="${i.name}-template.csv"`),e.send(r.join(",")+"\n")}async bulkImport(t,i,n){if(!t.file)return r(i,"No file uploaded");const a="true"===t.body.dryRun||!0===t.body.dryRun,c="true"===t.body.updateExisting||!0===t.body.updateExisting,l="false"!==t.body.continueOnError&&!1!==t.body.continueOnError,p=Date.now(),d={success:!0,total:0,created:0,updated:0,skipped:0,failed:0,errors:[],details:{duration:"",linesProcessed:0}};try{const r=t.file.buffer.toString("utf-8"),s=e(r,{columns:!0,skip_empty_lines:!0,trim:!0});d.total=s.length;for(let e=0;e<s.length;e++){const i=e+2,r=s[e];try{const{dn:e,entry:i}=await this.processRecord(r,n),s=await this.entryExists(e);if(s&&!c){d.skipped++;continue}if(a){d.created++;continue}s&&c?(await this.server.ldap.modify(e,{replace:i},t),d.updated++):(await this.server.ldap.add(e,i,t),d.created++)}catch(t){if(d.failed++,d.errors.push({line:i,identifier:r[n.mainAttribute],error:t.message,...t instanceof o?t.refusal:void 0}),!l)throw t}}d.details.linesProcessed=s.length,d.details.duration=`${((Date.now()-p)/1e3).toFixed(1)}s`,i.json(d)}catch(t){return s(i,t)}}async processRecord(t,e){const i={},r=e.schema,s=t.organizationDn;delete t.organizationDn;for(const[t,e]of Object.entries(r.properties||{}))if(!0===e.fixed){const r=e.default||e.const;void 0!==r&&(i[t]=r)}for(const[e,r]of Object.entries(t))if(r&&""!==r.trim()){if(!l.test(e))throw new Error(`Invalid column name: ${e}`);i[e]=r.includes(";")?r.split(";").map(t=>t.trim()):r}if(s){const t=this.findAttributeByRole(r,"organizationLink");t&&(i[t]=s);const e=this.findAttributeByRole(r,"organizationPath");if(e){const t=(await this.fetchOrganization(s))[e];i[e]=Array.isArray(t)?t[0]:t}}for(const[t,e]of Object.entries(r.properties||{}))if(!0===e.required&&!i[t])throw new Error(`Missing required attribute: ${t}`);const o=e.mainAttribute,n=i[o];if(!n)throw new Error(`Missing main attribute: ${o}`);const p=Array.isArray(n)?String(n[0]):String(n);a(p,o);return{dn:`${o}=${c(p)},${e.base}`,entry:i}}getEditableAttributes(t){const e=[];for(const[i,r]of Object.entries(t.properties||{}))!0!==r.fixed&&(r.role?.includes("organizationLink")||r.role?.includes("organizationPath")||e.push(i));return e}findAttributeByRole(t,e){for(const[i,r]of Object.entries(t.properties||{}))if(r.role?.includes(e))return i;return null}async fetchOrganization(t){const e=await this.server.ldap.search({paged:!1,scope:"base"},t);if(0===e.searchEntries.length)throw new Error(`Organization not found: ${t}`);return e.searchEntries[0]}async entryExists(t){try{return(await this.server.ldap.search({paged:!1,scope:"base"},t)).searchEntries.length>0}catch(t){return!1}}getConfigApiData(){const t=this.config.api_prefix||"/api",e=[];return this.resources.forEach((i,r)=>{e.push({name:r,mainAttribute:i.mainAttribute,base:i.base,maxFileSize:parseInt(this.config.bulk_import_max_file_size,10)||10485760,batchSize:parseInt(this.config.bulk_import_batch_size,10)||100,endpoints:{template:`${t}/v1/ldap/bulk-import/${r}/template.csv`,import:`${t}/v1/ldap/bulk-import/${r}`}})}),{enabled:!0,resources:e}}}export{LdapBulkImport as default};
@@ -1 +1 @@
1
- import e from"../../abstract/plugin.js";import{roleAttribute as t,hasRole as r,invalidValueMessage as n}from"../../config/schema.js";import{movedToTrash as i,normalizeDn as a,isDnInBranch as s,getParentDn as o,rdnValue as c,escapeLdapFilter as u,isDummyMemberDn as l}from"../../lib/utils.js";import{BadRequestError as f,ConflictError as d}from"../../lib/errors.js";import{extractLdapCode as h}from"../../lib/ldapCodes.js";function valueList(e){return null==e?[]:Array.isArray(e)?e.map(e=>String(e)):[String(e)]}function entryValues(e,t){const r=t.toLowerCase();for(const[t,n]of Object.entries(e))if(t.split(";")[0].toLowerCase()===r)return valueList(n);return[]}function asDn(e){try{return a(e)}catch{return e}}const m={B:1,KB:1e3,MB:1e6,GB:1e9,TB:1e12};function parseByteSize(e){if("number"==typeof e)return Math.round(e);const t=String(e).trim(),r=/^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)?$/i.exec(t);if(!r)throw new f(`"${t}" is not a valid size`);const n=(r[2]||"B").toUpperCase();return Math.round(parseFloat(r[1])*m[n])}function fillsGeneratedAttribute(e,r,n){return!(void 0===r.default||!r.generated&&!r.normalize)||t(n,"organizationPath")===e}function parseDirectoryDate(e){const t=String(e).trim(),r=/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})?(\d{2})?(?:[.,](\d+))?(Z|[+-]\d{2}(?::?\d{2})?)?$/.exec(t);if(!r){const e=new Date(t);return isNaN(e.getTime())?null:e}const[,n,i,a,s,o,c,u,l]=r,[f,d,h,m,p,g]=[n,i,a,s,o,c].map(e=>void 0===e?0:Number(e));if(d<1||d>12||m>23||p>59||g>60)return null;let b=Date.UTC(f,d-1,h,m,p,g);const y=new Date(b);if(y.getUTCMonth()!==d-1||y.getUTCDate()!==h)return null;if(void 0!==u){const e=void 0!==c?1e3:void 0!==o?6e4:36e5;b+=Math.round(Number(`0.${u}`)*e)}if(l&&"Z"!==l){const e=l.slice(1).replace(":",""),t=Number(e.slice(0,2)),r=e.length>2?Number(e.slice(2,4)):0;if(t>23||r>59)return null;b-=("-"===l[0]?-1:1)*(60*t+r)*6e4}return new Date(b)}function startOfUtcDay(e=new Date){return Date.UTC(e.getUTCFullYear(),e.getUTCMonth(),e.getUTCDate())}function mailInDomains(e,t,r=!1){if(0===t.length)return!0;if(t.includes("*"))return!0;const n=e.lastIndexOf("@");if(n<0)return!1;const i=e.slice(n+1).toLowerCase();return t.some(e=>{const t=e.toLowerCase();return i===t||r&&i.endsWith(`.${t}`)})}class LdapEnterpriseRules extends e{constructor(e){super(e),this.name="ldapEnterpriseRules",this.roles=["consistency"],this.hooks={ldapaddrequest:async([e,t,r])=>{const n=this.resolveEntity(e);return n&&(await this.runChecks(n,e,t,null),await this.runTransformations(n,e,t,!0)),void 0!==r?[e,t,r]:[e,t]},ldapmodifyrequest:async([e,r,n,i])=>{const a=this.resolveEntity(e);if(!a)return[e,r,n,i];const s={...r.add||{},...r.replace||{}};if(0===Object.keys(s).length)return[e,r,n,i];const o=await this.readEntry(e),c=t(a.schema,"organizationLink"),u={...s};if(c&&c in s&&o)for(const[e,t]of Object.entries(a.schema.attributes))t.mailDomainScope&&(e in u||void 0!==o[e]&&(u[e]=o[e]));await this.runChecks(a,e,u,o),await this.runTransformations(a,e,s,!1);for(const[e,t]of Object.entries(s))r.replace&&e in r.replace?r.replace[e]=t:r.add&&e in r.add?r.add[e]=t:(r.replace=r.replace||{},r.replace[e]=t);return[e,r,n,i]},ldapdeleterequest:async([e,t])=>{for(const t of Array.isArray(e)?e:[e])await this.guardDelete(t);return[e,t]},ldapdeletedone:async(e,t)=>{for(const r of Array.isArray(e)?e:[e])await this.removeReferences(r,t)},ldaprenamedone:async([e,t],r)=>{i(this.config.trash_base,e,t)&&await this.removeReferences(e,r)}},this.dummyMember=this.config.group_dummy_user,this.topOrganization=this.config.ldap_top_organization,this.logger.info("Enterprise directory rules enabled")}fillsGeneratedAttribute(e,t,r){return fillsGeneratedAttribute(e,t,r)}bindings(){const e=[],t=this.server.loadedPlugins.ldapFlatGeneric;for(const r of t?.instances||[])r.schema&&e.push({base:r.base,subtree:!1,schema:r.schema,label:r.singularName});const r=this.server.loadedPlugins.ldapGroups;r?.base&&r.schema&&e.push({base:r.base,subtree:!0,schema:r.schema,label:"group"});const n=this.server.loadedPlugins.ldapOrganizations;return n?.schema&&this.topOrganization&&e.push({base:this.topOrganization,subtree:!0,schema:n.schema,label:"organization"}),e}resolveEntity(e){const t=a(e);let r,n=-1;for(const i of this.bindings()){const c=a(i.base);if(t===c)continue;if(!s(e,i.base))continue;if(!i.subtree&&a(o(e))!==c)continue;const u=c.split(",").length;u>n&&(r=i,n=u)}return r}async runChecks(e,t,n,i){for(const[a,s]of Object.entries(n)){const o=e.schema.attributes[a];if(!o)continue;const c=valueList(s);0!==c.length&&(o.unique&&await this.checkUnique(e,a,o,c,i?t:null),o.mailDomainScope&&await this.checkMailDomains(e,o,c,n,i),r(o,"accountExpiry")&&this.checkNotInThePast(a,o,c))}}async runTransformations(e,r,i,a){for(const[t,r]of Object.entries(e.schema.attributes)){if(a&&void 0===i[t]&&void 0!==r.default&&(r.generated||r.normalize)&&(i[t]=Array.isArray(r.default)?r.default.map(e=>String(e)):String(r.default),"pointer"===r.type||"pointer"===r.items?.type))for(const e of valueList(i[t]))if(!await this.readEntry(e))throw new f(`Schema default for "${t}" points to a non-existent entry: ${e}`);if("byteSize"===r.normalize&&void 0!==i[t]){const e=valueList(i[t]);try{i[t]=Array.isArray(i[t])?e.map(e=>String(parseByteSize(e))):String(parseByteSize(e[0]))}catch{throw new f(n(t,r))}}}const s=t(e.schema,"organizationPath");if(!s)return;if(!e.schema.attributes[s]?.generated&&void 0!==i[s])return;const u=t(e.schema,"organizationLink");if(u){const e=valueList(i[u])[0];if(!e)return;const t=await this.organizationPath(e);if(void 0===t)throw new f(`Organization ${e} has no ${s}, so "${s}" cannot be computed`);return void(i[s]=t)}if(!a)return;const l=valueList(i[t(e.schema,"identifier")||""])[0]||c(r);if(!l)return;const d=o(r),h=(this.topOrganization||this.config.ldap_base||"").toLowerCase(),m=!h||d.toLowerCase().endsWith(`,${h}`)?await this.organizationPath(d):void 0,p=this.config.ldap_organization_path_separator||" / ";i[s]=m?`${m}${p}${l}`:l}async checkUnique(e,t,r,n,i){const a="object"==typeof r.unique?r.unique:{},s=[t,...a.attributes||[]],o=function(e,t){const contains=(e,t)=>t===e||t.endsWith(`,${e}`),r=[];for(const n of[e,...t||[]]){const e=n.toLowerCase();if(!r.some(t=>contains(t.toLowerCase(),e))){for(let t=r.length-1;t>=0;t--)contains(e,r[t].toLowerCase())&&r.splice(t,1);r.push(n)}}return r}(e.base,a.branches);for(const r of n){if(void 0!==a.sentinel&&r===a.sentinel)continue;const n=s.map(e=>`(${e}=${u(r)})`).join("");let c=s.length>1?`(|${n})`:n;a.filter&&(c=`(&${a.filter}${c})`);for(const n of o){const a=await this.server.ldap.search({paged:!1,scope:"sub",filter:c,attributes:["dn"]},n);for(const n of a.searchEntries||[]){const a=String(n.dn);if(!i||a.toLowerCase()!==i.toLowerCase())throw this.logger.warn(`${t}="${r}" refused for ${i||"a new entry"}: already held by ${a}`),new d(`${e.label}: "${r}" is already used (${t} must be unique)`)}}}}async checkMailDomains(e,t,r,n,i){const a="directory"===t.mailDomainScope?await this.directoryDomains():await this.organizationDomains(e,n,i);if(0!==a.length)for(const e of r)if(!mailInDomains(e,a,t.allowSubdomains))throw new d(`The mail domain of "${e}" is not one of the authorised domains (${a.join(", ")})`)}async organizationDomains(e,r,n){const i=t(e.schema,"organizationLink");if(!i)return[];const a=valueList(r[i])[0]||valueList(n?.[i])[0]||void 0;if(!a)return[];const s=this.bindings().find(e=>"organization"===e.label)?.schema,c=t(s,"domainLink")||this.config.enterprise_domain_link_attribute;if(!c)return[];const u=[],l=new Set;let f=a;const d=(this.topOrganization||this.config.ldap_base||"").toLowerCase(),h=this.domainNameAttribute();for(;f&&!l.has(f.toLowerCase());){l.add(f.toLowerCase());const e=await this.readEntry(f);for(const t of valueList(e?.[c])){const e=await this.readEntry(t),r=valueList(e?.[h]);0===r.length&&this.logger.warn(`Organization ${f} links to domain ${t}, which has no "${h}": no mail domain restriction comes from it`),u.push(...r)}if(f.toLowerCase()===d)break;const t=o(f);f=t===f?void 0:t}return u}domainNameAttribute(){for(const e of this.bindings()){const r=t(e.schema,"domainName");if(r)return r}return this.config.enterprise_domain_name_attribute||"associatedDomain"}async directoryDomains(){const e=this.config.ldap_base;if(!e)return[];const t=this.domainNameAttribute(),r=await this.server.ldap.search({paged:!1,scope:"sub",filter:`(${t}=*)`,attributes:[t]},e),n=[];for(const e of r.searchEntries||[])n.push(...valueList(e[t]));return n}checkNotInThePast(e,t,r){const i=startOfUtcDay();for(const a of r){const r=parseDirectoryDate(a);if(!r)throw new f(n(e,t));if(r.getTime()<i)throw new f(`Attribute "${e}" must not be earlier than today`)}}async organizationPath(e){const r=this.bindings().find(e=>"organization"===e.label)?.schema,n=t(r,"organizationPath")||this.config.ldap_organization_path_attribute;if(!n)return;const i=await this.readEntry(e);return i?valueList(i[n])[0]:void 0}async guardDelete(e){const t=this.resolveEntity(e);if(t)for(const[r,n]of Object.entries(t.schema.attributes)){if("nonEmpty"!==n.deleteGuard)continue;const i=await this.readEntry(e),a=valueList(i?.[r]).filter(e=>!l(e,this.dummyMember));if(a.length>0)throw new d(`Cannot delete ${t.label} ${e}: it still has ${a.length} member(s)`)}await this.guardReferences(e)}async guardReferences(e){for(const t of this.referringAttributes(e)){if(!t.pointer)continue;if("restrict"!==t.attr.referentialIntegrity)continue;const r=(await this.searchReferrers(t,e))[0];if(r)throw this.logger.warn(`Delete of ${e} refused: still referenced by ${String(r.dn)} (${t.name})`),new d(`Cannot delete ${e}: still referenced by another entry (${t.name})`)}}async removeReferences(e,t){const r=asDn(e),n=new Set;for(const i of this.referringAttributes(e)){if("remove"!==i.attr.referentialIntegrity)continue;let a;try{a=await this.searchReferrers(i,e)}catch(t){32!==h(t)&&this.logger.error(`Delete of ${e}: could not look for ${i.name} references: ${String(t)}`);continue}for(const s of a){const a=String(s.dn),o=`${asDn(a)} ${i.name.toLowerCase()}`;if(n.has(o))continue;n.add(o);const c=entryValues(s,i.name).filter(e=>asDn(e)===r);if(0!==c.length)try{await this.server.ldap.modify(a,{delete:{[i.name]:c}},void 0,{context:t})}catch(t){if(16===h(t))continue;this.logger.error(`Delete of ${e}: could not remove it from ${i.name} of ${a}: ${String(t)}`)}}}}referringAttributes(e){const t=o(e),n=[];for(const e of this.bindings())for(const[i,a]of Object.entries(e.schema.attributes)){const o="pointer"===a.type?a:a.items,c=r(a,"members")||r(a,"owners");if(!o&&!c)continue;const u=o?.branch||[];u.length>0&&!u.some(e=>s(t,e))||n.push({binding:e,name:i,attr:a,pointer:o,multiValued:"array"===a.type})}return n}async searchReferrers(e,t){return(await this.server.ldap.search({paged:!1,scope:"sub",filter:`(${e.name}=${u(t)})`,attributes:["dn",e.name]},e.binding.base)).searchEntries||[]}async findReferences(e){const t=[],r=new Set;return await Promise.all(this.referringAttributes(e).map(n=>this.server.ldap.queryLimit(async()=>{for(const i of await this.searchReferrers(n,e)){const e=String(i.dn),a=`${asDn(e)} ${n.name.toLowerCase()}`;r.has(a)||(r.add(a),t.push({dn:e,attribute:n.name,values:entryValues(i,n.name),multiValued:n.multiValued}))}}))),t}static referenceKey(e){return`${asDn(e.dn)} ${e.attribute.toLowerCase()}`}async rewriteReferences(e,t,r,n){const i=asDn(t),a=asDn(r);if(i===a)return{updated:0,failed:[]};const s=n?this.server.ldap.forRequest(n):{modify:(e,t)=>this.server.ldap.modify(e,t)},o=new Map;for(const e of await this.findReferences(t))o.set(LdapEnterpriseRules.referenceKey(e),e);let c=e.filter(e=>!o.has(LdapEnterpriseRules.referenceKey(e))).length;const u=new Map;return await Promise.all([...o.values()].map(e=>this.server.ldap.queryLimit(async()=>{try{await this.applyRewrite(s,e,i,a,r),c++}catch(n){this.logger.error(`Rename ${t} -> ${r}: could not rewrite ${e.attribute} of ${e.dn}: ${String(n)}`),u.set(e.attribute,(u.get(e.attribute)||0)+1)}}))),{updated:c,failed:[...u].map(([e,t])=>({attribute:e,count:t}))}}async applyRewrite(e,t,r,n,i){const a=this.rewrite(t,r,n,i);if(!a)return;try{return void await e.modify(t.dn,a)}catch(e){const r=e.code;if(20!==r&&16!==r)throw e;this.logger.debug(`Rewrite of ${t.attribute} on ${t.dn} met a concurrent one (code ${r}); reading it back`)}const s={...t,values:await this.freshValues(t.dn,t.attribute)},o=this.rewrite(s,r,n,i);o&&await e.modify(s.dn,o)}async freshValues(e,t){this.server.ldap.invalidateCache(e);const r=await this.server.ldap.search({paged:!1,scope:"base",attributes:[t]},e),n=r.searchEntries?.[0];return n?entryValues(n,t):[]}rewrite(e,t,r,n){const i=e.attribute;if(t===r)return;const a=e.values.filter(e=>asDn(e)===t);if(0===a.length)return;const s=e.values.some(e=>asDn(e)===r);if(!e.multiValued){const r=[];for(const i of e.values){const e=asDn(i)===t?n:i;r.some(t=>asDn(t)===asDn(e))||r.push(e)}return{replace:{[i]:r}}}return s?{delete:{[i]:a}}:{delete:{[i]:a},add:{[i]:n}}}async readEntry(e){try{const t=await this.server.ldap.search({paged:!1,scope:"base"},e);return t?.searchEntries?.[0]||null}catch(e){return null}}}export{LdapEnterpriseRules as default,fillsGeneratedAttribute,mailInDomains,parseByteSize,parseDirectoryDate,startOfUtcDay};
1
+ import e from"../../abstract/plugin.js";import{roleAttribute as t,hasRole as r,invalidValueMessage as n}from"../../config/schema.js";import{movedToTrash as i,normalizeDn as a,isDnInBranch as s,getParentDn as o,rdnValue as c,escapeLdapFilter as u,isDummyMemberDn as l}from"../../lib/utils.js";import{BadRequestError as f,ConflictError as d}from"../../lib/errors.js";import{extractLdapCode as h}from"../../lib/ldapCodes.js";function valueList(e){return null==e?[]:Array.isArray(e)?e.map(e=>String(e)):[String(e)]}function entryValues(e,t){const r=t.toLowerCase();for(const[t,n]of Object.entries(e))if(t.split(";")[0].toLowerCase()===r)return valueList(n);return[]}function asDn(e){try{return a(e)}catch{return e}}const m={B:1,KB:1e3,MB:1e6,GB:1e9,TB:1e12};function parseByteSize(e){if("number"==typeof e)return Math.round(e);const t=String(e).trim(),r=/^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)?$/i.exec(t);if(!r)throw new f(`"${t}" is not a valid size`);const n=(r[2]||"B").toUpperCase();return Math.round(parseFloat(r[1])*m[n])}function fillsGeneratedAttribute(e,r,n){return!(void 0===r.default||!r.generated&&!r.normalize)||t(n,"organizationPath")===e}function parseDirectoryDate(e){const t=String(e).trim(),r=/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})?(\d{2})?(?:[.,](\d+))?(Z|[+-]\d{2}(?::?\d{2})?)?$/.exec(t);if(!r){const e=new Date(t);return isNaN(e.getTime())?null:e}const[,n,i,a,s,o,c,u,l]=r,[f,d,h,m,p,g]=[n,i,a,s,o,c].map(e=>void 0===e?0:Number(e));if(d<1||d>12||m>23||p>59||g>60)return null;let b=Date.UTC(f,d-1,h,m,p,g);const y=new Date(b);if(y.getUTCMonth()!==d-1||y.getUTCDate()!==h)return null;if(void 0!==u){const e=void 0!==c?1e3:void 0!==o?6e4:36e5;b+=Math.round(Number(`0.${u}`)*e)}if(l&&"Z"!==l){const e=l.slice(1).replace(":",""),t=Number(e.slice(0,2)),r=e.length>2?Number(e.slice(2,4)):0;if(t>23||r>59)return null;b-=("-"===l[0]?-1:1)*(60*t+r)*6e4}return new Date(b)}function startOfUtcDay(e=new Date){return Date.UTC(e.getUTCFullYear(),e.getUTCMonth(),e.getUTCDate())}function mailInDomains(e,t,r=!1){if(0===t.length)return!0;if(t.includes("*"))return!0;const n=e.lastIndexOf("@");if(n<0)return!1;const i=e.slice(n+1).toLowerCase();return t.some(e=>{const t=e.toLowerCase();return i===t||r&&i.endsWith(`.${t}`)})}class LdapEnterpriseRules extends e{constructor(e){super(e),this.name="ldapEnterpriseRules",this.roles=["consistency"],this.hooks={ldapaddrequest:async([e,t,r])=>{const n=this.resolveEntity(e);return n&&(await this.runChecks(n,e,t,null),await this.runTransformations(n,e,t,!0)),void 0!==r?[e,t,r]:[e,t]},ldapmodifyrequest:async([e,r,n,i])=>{const a=this.resolveEntity(e);if(!a)return[e,r,n,i];const s={...r.add||{},...r.replace||{}};if(0===Object.keys(s).length)return[e,r,n,i];const o=await this.readEntry(e),c=t(a.schema,"organizationLink"),u={...s};if(c&&c in s&&o)for(const[e,t]of Object.entries(a.schema.attributes))t.mailDomainScope&&(e in u||void 0!==o[e]&&(u[e]=o[e]));await this.runChecks(a,e,u,o),await this.runTransformations(a,e,s,!1);for(const[e,t]of Object.entries(s))r.replace&&e in r.replace?r.replace[e]=t:r.add&&e in r.add?r.add[e]=t:(r.replace=r.replace||{},r.replace[e]=t);return[e,r,n,i]},ldapdeleterequest:async([e,t])=>{for(const t of Array.isArray(e)?e:[e])await this.guardDelete(t);return[e,t]},ldapdeletedone:async(e,t)=>{for(const r of Array.isArray(e)?e:[e])await this.removeReferences(r,t)},ldaprenamedone:async([e,t],r)=>{i(this.config.trash_base,e,t)&&await this.removeReferences(e,r)}},this.dummyMember=this.config.group_dummy_user,this.topOrganization=this.config.ldap_top_organization,this.logger.info("Enterprise directory rules enabled")}fillsGeneratedAttribute(e,t,r){return fillsGeneratedAttribute(e,t,r)}bindings(){const e=[],t=this.server.loadedPlugins.ldapFlatGeneric;for(const r of t?.instances||[])r.schema&&e.push({base:r.base,subtree:!1,schema:r.schema,label:r.singularName});const r=this.server.loadedPlugins.ldapGroups;r?.base&&r.schema&&e.push({base:r.base,subtree:!0,schema:r.schema,label:"group"});const n=this.server.loadedPlugins.ldapOrganizations;return n?.schema&&this.topOrganization&&e.push({base:this.topOrganization,subtree:!0,schema:n.schema,label:"organization"}),e}resolveEntity(e){const t=a(e);let r,n=-1;for(const i of this.bindings()){const c=a(i.base);if(t===c)continue;if(!s(e,i.base))continue;if(!i.subtree&&a(o(e))!==c)continue;const u=c.split(",").length;u>n&&(r=i,n=u)}return r}async runChecks(e,t,n,i){for(const[a,s]of Object.entries(n)){const o=e.schema.attributes[a];if(!o)continue;const c=valueList(s);0!==c.length&&(o.unique&&await this.checkUnique(e,a,o,c,i?t:null),o.mailDomainScope&&await this.checkMailDomains(e,a,o,c,n,i),r(o,"accountExpiry")&&this.checkNotInThePast(a,o,c))}}async runTransformations(e,r,i,a){for(const[t,r]of Object.entries(e.schema.attributes)){if(a&&void 0===i[t]&&void 0!==r.default&&(r.generated||r.normalize)&&(i[t]=Array.isArray(r.default)?r.default.map(e=>String(e)):String(r.default),"pointer"===r.type||"pointer"===r.items?.type))for(const e of valueList(i[t]))if(!await this.readEntry(e))throw new f(`Schema default for "${t}" points to a non-existent entry: ${e}`);if("byteSize"===r.normalize&&void 0!==i[t]){const e=valueList(i[t]);try{i[t]=Array.isArray(i[t])?e.map(e=>String(parseByteSize(e))):String(parseByteSize(e[0]))}catch{throw new f(n(t,r))}}}const s=t(e.schema,"organizationPath");if(!s)return;if(!e.schema.attributes[s]?.generated&&void 0!==i[s])return;const u=t(e.schema,"organizationLink");if(u){const e=valueList(i[u])[0];if(!e)return;const t=await this.organizationPath(e);if(void 0===t)throw new f(`Organization ${e} has no ${s}, so "${s}" cannot be computed`);return void(i[s]=t)}if(!a)return;const l=valueList(i[t(e.schema,"identifier")||""])[0]||c(r);if(!l)return;const d=o(r),h=(this.topOrganization||this.config.ldap_base||"").toLowerCase(),m=!h||d.toLowerCase().endsWith(`,${h}`)?await this.organizationPath(d):void 0,p=this.config.ldap_organization_path_separator||" / ";i[s]=m?`${m}${p}${l}`:l}async checkUnique(e,t,r,n,i){const a="object"==typeof r.unique?r.unique:{},s=[t,...a.attributes||[]],o=function(e,t){const contains=(e,t)=>t===e||t.endsWith(`,${e}`),r=[];for(const n of[e,...t||[]]){const e=n.toLowerCase();if(!r.some(t=>contains(t.toLowerCase(),e))){for(let t=r.length-1;t>=0;t--)contains(e,r[t].toLowerCase())&&r.splice(t,1);r.push(n)}}return r}(e.base,a.branches);for(const r of n){if(void 0!==a.sentinel&&r===a.sentinel)continue;const n=s.map(e=>`(${e}=${u(r)})`).join("");let c=s.length>1?`(|${n})`:n;a.filter&&(c=`(&${a.filter}${c})`);for(const n of o){const a=await this.server.ldap.search({paged:!1,scope:"sub",filter:c,attributes:["dn"]},n);for(const n of a.searchEntries||[]){const a=String(n.dn);if(!i||a.toLowerCase()!==i.toLowerCase())throw this.logger.warn(`${t}="${r}" refused for ${i||"a new entry"}: already held by ${a}`),new d(`${e.label}: "${r}" is already used (${t} must be unique)`,{code:"unique",attribute:t,value:r})}}}}async checkMailDomains(e,t,r,n,i,a){const s="directory"===r.mailDomainScope?await this.directoryDomains():await this.organizationDomains(e,i,a);if(0!==s.length)for(const e of n)if(!mailInDomains(e,s,r.allowSubdomains))throw new d(`The mail domain of "${e}" is not one of the authorised domains (${s.join(", ")})`,{code:"mailDomain",attribute:t,value:e,domains:s})}async organizationDomains(e,r,n){const i=t(e.schema,"organizationLink");if(!i)return[];const a=valueList(r[i])[0]||valueList(n?.[i])[0]||void 0;if(!a)return[];const s=this.bindings().find(e=>"organization"===e.label)?.schema,c=t(s,"domainLink")||this.config.enterprise_domain_link_attribute;if(!c)return[];const u=[],l=new Set;let f=a;const d=(this.topOrganization||this.config.ldap_base||"").toLowerCase(),h=this.domainNameAttribute();for(;f&&!l.has(f.toLowerCase());){l.add(f.toLowerCase());const e=await this.readEntry(f);for(const t of valueList(e?.[c])){const e=await this.readEntry(t),r=valueList(e?.[h]);0===r.length&&this.logger.warn(`Organization ${f} links to domain ${t}, which has no "${h}": no mail domain restriction comes from it`),u.push(...r)}if(f.toLowerCase()===d)break;const t=o(f);f=t===f?void 0:t}return u.filter((e,t)=>u.findIndex(t=>t.toLowerCase()===e.toLowerCase())===t)}domainNameAttribute(){for(const e of this.bindings()){const r=t(e.schema,"domainName");if(r)return r}return this.config.enterprise_domain_name_attribute||"associatedDomain"}async directoryDomains(){const e=this.config.ldap_base;if(!e)return[];const t=this.domainNameAttribute(),r=await this.server.ldap.search({paged:!1,scope:"sub",filter:`(${t}=*)`,attributes:[t]},e),n=[];for(const e of r.searchEntries||[])n.push(...valueList(e[t]));return n}checkNotInThePast(e,t,r){const i=startOfUtcDay();for(const a of r){const r=parseDirectoryDate(a);if(!r)throw new f(n(e,t));if(r.getTime()<i)throw new f(`Attribute "${e}" must not be earlier than today`)}}async organizationPath(e){const r=this.bindings().find(e=>"organization"===e.label)?.schema,n=t(r,"organizationPath")||this.config.ldap_organization_path_attribute;if(!n)return;const i=await this.readEntry(e);return i?valueList(i[n])[0]:void 0}async guardDelete(e){const t=this.resolveEntity(e);if(t)for(const[r,n]of Object.entries(t.schema.attributes)){if("nonEmpty"!==n.deleteGuard)continue;const i=await this.readEntry(e),a=valueList(i?.[r]).filter(e=>!l(e,this.dummyMember));if(a.length>0)throw new d(`Cannot delete ${t.label} ${e}: it still has ${a.length} member(s)`)}await this.guardReferences(e)}async guardReferences(e){for(const t of this.referringAttributes(e)){if(!t.pointer)continue;if("restrict"!==t.attr.referentialIntegrity)continue;const r=(await this.searchReferrers(t,e))[0];if(r)throw this.logger.warn(`Delete of ${e} refused: still referenced by ${String(r.dn)} (${t.name})`),new d(`Cannot delete ${e}: still referenced by another entry (${t.name})`)}}async removeReferences(e,t){const r=asDn(e),n=new Set;for(const i of this.referringAttributes(e)){if("remove"!==i.attr.referentialIntegrity)continue;let a;try{a=await this.searchReferrers(i,e)}catch(t){32!==h(t)&&this.logger.error(`Delete of ${e}: could not look for ${i.name} references: ${String(t)}`);continue}for(const s of a){const a=String(s.dn),o=`${asDn(a)} ${i.name.toLowerCase()}`;if(n.has(o))continue;n.add(o);const c=entryValues(s,i.name).filter(e=>asDn(e)===r);if(0!==c.length)try{await this.server.ldap.modify(a,{delete:{[i.name]:c}},void 0,{context:t})}catch(t){if(16===h(t))continue;this.logger.error(`Delete of ${e}: could not remove it from ${i.name} of ${a}: ${String(t)}`)}}}}referringAttributes(e){const t=o(e),n=[];for(const e of this.bindings())for(const[i,a]of Object.entries(e.schema.attributes)){const o="pointer"===a.type?a:a.items,c=r(a,"members")||r(a,"owners");if(!o&&!c)continue;const u=o?.branch||[];u.length>0&&!u.some(e=>s(t,e))||n.push({binding:e,name:i,attr:a,pointer:o,multiValued:"array"===a.type})}return n}async searchReferrers(e,t){return(await this.server.ldap.search({paged:!1,scope:"sub",filter:`(${e.name}=${u(t)})`,attributes:["dn",e.name]},e.binding.base)).searchEntries||[]}async findReferences(e){const t=[],r=new Set;return await Promise.all(this.referringAttributes(e).map(n=>this.server.ldap.queryLimit(async()=>{for(const i of await this.searchReferrers(n,e)){const e=String(i.dn),a=`${asDn(e)} ${n.name.toLowerCase()}`;r.has(a)||(r.add(a),t.push({dn:e,attribute:n.name,values:entryValues(i,n.name),multiValued:n.multiValued}))}}))),t}static referenceKey(e){return`${asDn(e.dn)} ${e.attribute.toLowerCase()}`}async rewriteReferences(e,t,r,n){const i=asDn(t),a=asDn(r);if(i===a)return{updated:0,failed:[]};const s=n?this.server.ldap.forRequest(n):{modify:(e,t)=>this.server.ldap.modify(e,t)},o=new Map;for(const e of await this.findReferences(t))o.set(LdapEnterpriseRules.referenceKey(e),e);let c=e.filter(e=>!o.has(LdapEnterpriseRules.referenceKey(e))).length;const u=new Map;return await Promise.all([...o.values()].map(e=>this.server.ldap.queryLimit(async()=>{try{await this.applyRewrite(s,e,i,a,r),c++}catch(n){this.logger.error(`Rename ${t} -> ${r}: could not rewrite ${e.attribute} of ${e.dn}: ${String(n)}`),u.set(e.attribute,(u.get(e.attribute)||0)+1)}}))),{updated:c,failed:[...u].map(([e,t])=>({attribute:e,count:t}))}}async applyRewrite(e,t,r,n,i){const a=this.rewrite(t,r,n,i);if(!a)return;try{return void await e.modify(t.dn,a)}catch(e){const r=e.code;if(20!==r&&16!==r)throw e;this.logger.debug(`Rewrite of ${t.attribute} on ${t.dn} met a concurrent one (code ${r}); reading it back`)}const s={...t,values:await this.freshValues(t.dn,t.attribute)},o=this.rewrite(s,r,n,i);o&&await e.modify(s.dn,o)}async freshValues(e,t){this.server.ldap.invalidateCache(e);const r=await this.server.ldap.search({paged:!1,scope:"base",attributes:[t]},e),n=r.searchEntries?.[0];return n?entryValues(n,t):[]}rewrite(e,t,r,n){const i=e.attribute;if(t===r)return;const a=e.values.filter(e=>asDn(e)===t);if(0===a.length)return;const s=e.values.some(e=>asDn(e)===r);if(!e.multiValued){const r=[];for(const i of e.values){const e=asDn(i)===t?n:i;r.some(t=>asDn(t)===asDn(e))||r.push(e)}return{replace:{[i]:r}}}return s?{delete:{[i]:a}}:{delete:{[i]:a},add:{[i]:n}}}async readEntry(e){try{const t=await this.server.ldap.search({paged:!1,scope:"base"},e);return t?.searchEntries?.[0]||null}catch(e){return null}}}export{LdapEnterpriseRules as default,fillsGeneratedAttribute,mailInDomains,parseByteSize,parseDirectoryDate,startOfUtcDay};
@@ -1 +1 @@
1
- import e from"../../abstract/plugin.js";import{assertModifyRequest as t}from"../../lib/ldapActions.js";import{wantJson as r,jsonBody as i,tryMethod as s,ok as a}from"../../lib/expressFormatedResponses.js";import{extractLdapCode as o}from"../../lib/ldapCodes.js";import{loadSchemaFileAsync as n,schemaUrl as h}from"../../lib/schemaFile.js";import{movedToTrash as c,asyncHandler as d,isDummyMemberDn as p,escapeDnValue as m,validateDnValue as u,launchHooksChained as l,launchHooks as f,escapeLdapFilter as g,organizationLink as w,isAuthzRefusal as b,substringSearchFilter as y}from"../../lib/utils.js";import{NotFoundError as $,BadRequestError as v,HttpError as _}from"../../lib/errors.js";import{assertClientMaySet as A,modifiedAttributeNames as G,toDirectoryEntry as q,invalidValueMessage as C,missingRequiredAttribute as E,toDirectoryChanges as j,checkDnValues as k,matchesPattern as M}from"../../config/schema.js";class LdapGroups extends e{constructor(e){if(super(e),this.name="ldapGroups",this.roles=["api","configurable"],this.hooks={ldapdeletedone:async e=>{const t=Array.isArray(e)?e:[e];this.logger.debug(`User deletion detected, removing from groups: ${t.join(", ")}`),await Promise.all(t.map(e=>this.deleteMemberFromAll(e))).catch(e=>{this.logger.error("Failed to process user deletion in groups:",e)})},ldaprenamedone:async([e,t])=>{c(this.config.trash_base,e,t)&&await this.deleteMemberFromAll(e).catch(e=>{this.logger.error("Failed to process a move to the trash in groups:",e)})}},this.ldap=e.ldap,this.base=this.config.ldap_group_base,this.cn=this.config.ldap_groups_main_attribute,this.base||(this.base=this.config.ldap_base,this.logger.warn(`LDAP group base is not defined, using "${this.base}"`)),!this.base)throw new Error("LDAP base is not defined, please set --ldap-group-base");if(this.config.ldap_group_base&&e.ldap.declareObjectClasses(this.base,this.config.group_class||[]),this.config.group_schema){const e=this.config.group_schema;n(e,{config:this.config}).then(e=>{this.schema=this.adaptSchema(e),this.logger.debug("Group schema loaded")}).catch(t=>this.logger.error(`Failed to load group schema from ${e}: ${t}`))}}adaptSchema(e){return e}api(e){e.get(`${this.config.api_prefix}/v1/ldap/groups`,d(async(e,t)=>{if(!r(e,t))return;const i={};if(e.query.match){if("string"!=typeof e.query.match)throw new v("Invalid match query");if("string"==typeof e.query.attribute&&e.query.attribute)i.filter=y(e.query.match,e.query.attribute);else if(/=/.test(e.query.match)){if(!/^[\w*=()&|, .@'-]+$/.test(e.query.match))throw new v("Invalid match query: contains forbidden characters");i.filter=e.query.match}else{const t=g(e.query.match);i.filter=`(${this.cn}=${t})`}}e.query.attributes&&"string"==typeof e.query.attributes&&(i.attributes=e.query.attributes.split(","));const s=await this.listGroups({...i,req:e});return a(t,this.hideNeverReturn(s))})),e.get(`${this.config.api_prefix}/v1/ldap/groups/:cn`,d(async(e,t)=>this.apiGet(e,t))),e.post(`${this.config.api_prefix}/v1/ldap/groups`,d(async(e,t)=>this.apiAdd(e,t,this.cn))),e.delete(`${this.config.api_prefix}/v1/ldap/groups/:cn`,d(async(e,t)=>this.apiDelete(e,t))),e.put(`${this.config.api_prefix}/v1/ldap/groups/:cn`,d(async(e,t)=>this.apiModify(e,t))),e.post(`${this.config.api_prefix}/v1/ldap/groups/:cn/members`,d(async(e,t)=>{const r=decodeURIComponent(e.params.cn);if(!r)throw new v("cn is required");const a=i(e,t,"member");if(!a)return;if((Array.isArray(a.member)?a.member:[a.member]).some(e=>"string"!=typeof e))throw new v("member must be a string or string array");await s(t,this.addMember.bind(this),r,a.member,e)})),e.delete(`${this.config.api_prefix}/v1/ldap/groups/:cn/members/:member`,d(async(e,t)=>{if(!r(e,t))return;const i=decodeURIComponent(e.params.cn),a=decodeURIComponent(e.params.member);if(!i||!a)throw new v("cn and member are required");await s(t,this.deleteMember.bind(this),i,a,e)})),this.config.ldap_organization_link_attribute&&this.config.ldap_organization_path_attribute&&e.post(`${this.config.api_prefix}/v1/ldap/groups/:cn/move`,d(async(e,t)=>{if(!r(e,t))return;const a=decodeURIComponent(e.params.cn);if(!a)throw new v("cn is required");const o=i(e,t,"targetOrgDn");if(o){if("string"!=typeof o.targetOrgDn)throw new v("targetOrgDn must be a string");await s(t,this.moveGroup.bind(this),a,o.targetOrgDn,e)}})),e.post(`${this.config.api_prefix}/v1/ldap/groups/:cn/rename`,d(async(e,t)=>{if(!r(e,t))return;const a=decodeURIComponent(e.params.cn);if(!a)throw new v("cn is required");const o=i(e,t,"newCn");if(o){if("string"!=typeof o.newCn)throw new v("newCn must be a string");await s(t,this.renameGroup.bind(this),a,o.newCn,e)}}))}isDummyMember(e){return p(e,this.config.group_dummy_user)}withoutDummyMember(e){const t=e.member;if(void 0===t)return e;const r=Array.isArray(t)?t:[t];return{...e,member:r.filter(e=>!this.isDummyMember(e))}}async apiGet(e,t){if(!r(e,t))return;const i=decodeURIComponent(e.params.cn);try{const r=/,/.test(i)?i:`${this.cn}=${m(i)},${this.base}`,s=await this.ldap.search({paged:!1,scope:"base"},r,e);if(0===s.searchEntries.length)throw new $("Group not found");t.json(this.hideNeverReturn(this.withoutDummyMember(s.searchEntries[0])))}catch(e){if(e.code&&32===e.code)throw new $("Group not found");throw e}}async apiAdd(e,t,...r){const a=i(e,t,...r);if(!a)return;A(this.schema,Object.keys(a),this.cn);const o=a[this.cn],n=a.member?a.member:[],h=Object.fromEntries(Object.entries(a).filter(([e,t])=>e!==this.cn&&"member"!==e&&void 0!==t&&!(!0===this.schema?.attributes[e]?.fixed)));await s(t,this.addGroup.bind(this),o,n,h,e)}async apiDelete(e,t){if(!r(e,t))return;const i=decodeURIComponent(e.params.cn);if(!i)throw new v("cn is required");await s(t,this.deleteGroup.bind(this),i,e)}async apiModify(e,r){const a=i(e,r);if(!a)return;t(a);const o=this.fixDn(decodeURIComponent(e.params.cn));if(!o)throw new v("cn is required");A(this.schema,G(a));const n=Object.fromEntries(Object.entries(a).filter(([e,t])=>!(!0===this.schema?.attributes[e]?.fixed)));await s(r,this.modifyGroup.bind(this),o,n,e)}async addGroup(e,t=[],r={},i){let s;new RegExp(`^${this.cn}=`).test(e)?(s=e,e=e.replace(new RegExp(`^${this.cn}=((?:\\\\.|[^,])+)(?:,.*)?$`),"$1")):(u(e,this.cn),s=`${this.cn}=${m(e)},${this.base}`),await this.validateMembers(s,t,i),await this.validateNewGroup(s,{objectClass:this.config.group_class,cn:e,member:t,...r});const a=[...this.config.group_class];!(r.mail||r.twakeMailboxType||r.twakeDepartmentLink||r.twakeDepartmentPath)||a.includes("twakeGroup")||a.includes("twakeStaticGroup")||a.push("twakeGroup");let o,n={objectClass:a,...this.config.group_default_attributes,[this.cn]:e,member:[this.config.group_dummy_user],...r};t.length&&n.member.push(...t),q(this.schema,n),[s,n]=await l(this.registeredHooks.ldapgroupadd,[s,n]);try{o=await this.ldap.add(s,n,i)}catch(e){if(e instanceof _)throw e;throw new Error(`Failed to add group ${s}: ${e}`)}return f(this.registeredHooks.ldapgroupadddone,[s,n]),o}async modifyGroup(e,t,r){let i=/,/.test(e)?e:`${this.cn}=${m(e)},${this.base}`;const s=this.opNumber();if([i,t]=await l(this.registeredHooks.ldapgroupmodify,[i,t,s]),t.add){if(t.add.member)throw new v("Use dedicated API to add members");if(t.add[this.cn])throw new v(`${this.cn} attribute is unique, cannot add`)}if(t.delete){if(t.delete instanceof Object){if(t.delete.member)throw new v("Use dedicated API to delete members");if(t.delete[this.cn])throw new v(`Cannot delete ${this.cn} attribute`)}if(Array.isArray(t.delete)){if(t.delete.includes("member"))throw new v("Use dedicated API to delete members");if(t.delete.includes(this.cn))throw new v(`Cannot delete ${this.cn} attribute`)}}if(t.replace){if(t.replace.member)throw new v("Use dedicated API to replace members");if(t.replace[this.cn])throw new v(`Use dedicated API to change ${this.cn} attribute`)}await this.validateChanges(i,t);const a=await this.ldap.modify(i,t,r);return f(this.registeredHooks.ldapgroupmodifydone,[i,t,s]),a}async renameGroup(e,t,r){/,/.test(e)||u(e,this.cn),/,/.test(t)||u(t,this.cn);let i=/,/.test(e)?e:`${this.cn}=${m(e)},${this.base}`,s=/,/.test(t)?t:`${this.cn}=${m(t)},${this.base}`;[i,s]=await l(this.registeredHooks.ldapgrouprename,[i,s]);const a=await this.ldap.rename(i,s,r);return f(this.registeredHooks.ldapgrouprenamedone,[i,s]),a}async deleteGroup(e,t){let r=/,/.test(e)?e:`${this.cn}=${m(e)},${this.base}`;r=await l(this.registeredHooks.ldapgroupdelete,r);const i=await this.ldap.delete(r,t);return f(this.registeredHooks.ldapgroupdeletedone,r),i}async addMember(e,t,r){const i=/,/.test(e)?e:`${this.cn}=${m(e)},${this.base}`;return Array.isArray(t)||(t=[t]),[e,t]=await l(this.registeredHooks.ldapgroupaddmember,[e,t]),await this.validateMembers(i,t,r),await this.ldap.modify(i,{add:{member:t}},r).catch(e=>{if(e instanceof _)throw e;throw new Error(`Failed to add member(s) to ${i}: ${e}`)})}async deleteMember(e,t,r){const i=/,/.test(e)?e:`${this.cn}=${m(e)},${this.base}`;if([e,t]=await l(this.registeredHooks.ldapgroupdeletemember,[e,t]),this.isDummyMember(t))throw new v("Cannot delete dummy member from group");return await this.ldap.modify(i,{delete:{member:t}},r).catch(e=>{if(16===o(e))throw new $(`${t} is not a member of ${i}`);if(e instanceof _)throw e;throw new Error(`Failed to delete member ${t} from ${i}: ${e}`)})}async deleteMemberFromAll(e){const t=await this.ldap.search({filter:`member=${g(e)}`,paged:!1,attributes:[this.cn]},this.base).catch(e=>{throw new Error(`Failed to search groups from ${this.base}: ${e}`)});await Promise.all(t.searchEntries.map(t=>this.ldap.modify(t.dn,{delete:{member:e}}).catch(r=>{if(65===o(r)&&this.config.group_dummy_user)return this.ldap.modify(t.dn,{add:{member:this.config.group_dummy_user},delete:{member:e}});throw r}).catch(r=>{const i=o(r);16!==i&&32!==i&&this.logger.error(`Failed to remove ${e} from group ${t.dn}:`,r)})))}async moveGroup(e,t,r){const i=this.config.ldap_organization_link_attribute,s=this.config.ldap_organization_path_attribute,a=/,/.test(e)?e:`${this.cn}=${m(e)},${this.base}`;let n;try{n=await this.ldap.search({paged:!1,scope:"base"},a)}catch(e){if(32===o(e))throw new $(`Group ${a} not found`);throw e}if(0===n.searchEntries.length)throw new $(`Group ${a} not found`);const h=n.searchEntries[0],c=w(h[i]);if(!c)throw new v(`Group ${a} does not have ${i} attribute and cannot be moved`);if(c===t)throw new v("Group is already in the target organization");let d;try{d=await this.ldap.search({paged:!1,scope:"base",attributes:[s,"ou","o"]},t)}catch(e){const r=o(e);if(32===r)throw new $(`Target organization ${t} not found`);if(34===r)throw new v(`${t} is not a valid DN`);throw new Error(`Target organization ${t} not found: ${e instanceof Error&&e.message?e.message:String(e)}`)}if(0===d.searchEntries.length)throw new $(`Target organization ${t} not found`);const p=d.searchEntries[0][s];if(!p)throw new Error(`Target organization ${t} does not have ${s} attribute`);return await this.ldap.modify(a,{replace:{[i]:t,[s]:p}},r),this.logger.info(`Group ${a} moved from ${c} to ${t}`),{success:!0}}async listGroups({filter:e,attributes:t,req:r}={}){const i=await this.ldap.search({filter:e||"(objectClass=*)",attributes:t||[this.cn,"member"],paged:!0},this.base,r).catch(e=>{throw new Error(`Failed to list groups from ${this.base}: ${e}`)});let s={};for await(const e of i)for(const t of e.searchEntries){const e=t[this.cn];if(Array.isArray(e)&&0===e.length)continue;e&&(s[e]=this.withoutDummyMember(t))}return s=await l(this.registeredHooks._ldapgrouplist,s),s}async searchGroupsByName(e,t=!1,r=[this.cn,"member"],i){const s=g(e),a=t?`(${this.cn}=*${s}*)`:`(${this.cn}=${s})`;return await this.listGroups({filter:a,attributes:r,req:i})}fixDn(e){return!!e&&(/,/.test(e)?e:`${this.cn}=${m(e)},${this.base}`)}async validateMembers(e,t,r){[e,t]=await l(this.server.hooks.ldapgroupvalidatemembers,[e,t]);const i=Boolean(this.config.groups_allow_unexistent_members);if((!i||r)&&t&&t.length)try{await Promise.all(t.map(e=>this.server.ldap.queryLimit(async()=>{let t;try{t=await this.ldap.search({paged:!1,scope:"base"},e,r)}catch(t){if(b(t))throw t;if(i)return;const r=o(t);if(32===r)throw new v(`Member ${e} not found`);if(34===r)throw new v(`Member ${e} is not a valid DN`);throw new Error(`Member ${e} not found: ${t}`)}if(r&&!t.searchEntries?.length)throw new v(`Member ${e} not found`)})))}catch(e){if(e instanceof _||b(e))throw e;throw new Error(`Failed to find member(s): ${e}`)}}async validateNewGroup(e,t){if(!this.schema)return q(void 0,t),!0;[e,t]=await l(this.server.hooks.ldapgroupvalidatenew,[e,t]);for(const[e,r]of Object.entries(t))if(!await this._validateOneChange(e,r))throw new v(C(e,this.schema.attributes[e]));const r=E(this.schema,t,this.server.loadedPlugins);if(r)throw new v(`Attribute "${r}" is required`);return q(this.schema,t),!0}async validateChanges(e,t){if(!this.schema)return j(void 0,t),!0;if([e,t]=await l(this.server.hooks.ldapgroupvalidatechanges,[e,t]),t.add)for(const[e,r]of Object.entries(t.add))if(!await this._validateOneChange(e,r))throw new v(C(e,this.schema.attributes[e]));if(t.replace)for(const[e,r]of Object.entries(t.replace))if(!await this._validateOneChange(e,r))throw new v(C(e,this.schema.attributes[e]));if(t.delete&&t.delete instanceof Object)for(const e of Array.isArray(t.delete)?t.delete:Object.keys(t.delete))await this._validateOneChange(e,null);return j(this.schema,t),!0}async _validateOneChange(e,t){if(!this.schema)return!0;const r=this.schema.attributes[e];if(!r){if(this.schema.strict)throw new v(`Attribute "${e}" is not allowed`);return!0}if(null==t||Array.isArray(t)&&0===t.length){if(r.required)throw new v(`Attribute "${e}" is required`);return!0}if("array"===r.type){if(!Array.isArray(t))throw new v(`Attribute "${e}" must be an array`);if(!r.items)throw new Error(`Schema error: no item for array ${e}`);if("array"===r.items.type)throw new Error(`Schema error: array of array not supported for ${e}`);if(r.items.test&&"pointer"!==r.items.type)for(const i of t)if(typeof i!==r.items.type)throw new v(`Attribute "${e}" must be of type ${r.items.type}`)}else if("pointer"!==r.type&&typeof t!==r.type)return!1;if(await k(e,r,t,async e=>(await this.ldap.search({paged:!1,scope:"base",attributes:["dn"]},e)).searchEntries.length>0),!M(r,t))throw new v(C(e,r));return!0}getConfigApiData(){const e=this.config.api_prefix||"/api",t=this.server.loadedPlugins.static&&this.config.group_schema?h(this.config,this.config.group_schema):void 0;return{enabled:!0,base:this.base||"",mainAttribute:this.cn||"cn",objectClass:this.config.group_class||["top","groupOfNames"],schema:this.schema,schemaUrl:t,endpoints:{list:`${e}/v1/ldap/groups`,get:`${e}/v1/ldap/groups/:id`,create:`${e}/v1/ldap/groups`,update:`${e}/v1/ldap/groups/:id`,delete:`${e}/v1/ldap/groups/:id`,addMember:`${e}/v1/ldap/groups/:id/members`,removeMember:`${e}/v1/ldap/groups/:id/members/:memberId`,rename:`${e}/v1/ldap/groups/:id/rename`,move:`${e}/v1/ldap/groups/:id/move`}}}}export{LdapGroups as default};
1
+ import e from"../../abstract/plugin.js";import{assertModifyRequest as t}from"../../lib/ldapActions.js";import{wantJson as r,jsonBody as i,tryMethod as s,ok as a}from"../../lib/expressFormatedResponses.js";import{extractLdapCode as o}from"../../lib/ldapCodes.js";import{loadSchemaFileAsync as n,schemaUrl as h}from"../../lib/schemaFile.js";import{movedToTrash as c,asyncHandler as d,isDummyMemberDn as m,escapeDnValue as p,validateDnValue as u,launchHooksChained as l,launchHooks as f,escapeLdapFilter as g,organizationLink as w,isAuthzRefusal as b,substringSearchFilter as y}from"../../lib/utils.js";import{NotFoundError as $,BadRequestError as v,HttpError as _,ConflictError as A}from"../../lib/errors.js";import{assertClientMaySet as G,modifiedAttributeNames as C,toDirectoryEntry as q,invalidValueMessage as M,missingRequiredAttribute as E,toDirectoryChanges as j,checkDnValues as k,matchesPattern as O}from"../../config/schema.js";class LdapGroups extends e{constructor(e){if(super(e),this.name="ldapGroups",this.roles=["api","configurable"],this.hooks={ldapdeletedone:async e=>{const t=Array.isArray(e)?e:[e];this.logger.debug(`User deletion detected, removing from groups: ${t.join(", ")}`),await Promise.all(t.map(e=>this.deleteMemberFromAll(e))).catch(e=>{this.logger.error("Failed to process user deletion in groups:",e)})},ldaprenamedone:async([e,t])=>{c(this.config.trash_base,e,t)&&await this.deleteMemberFromAll(e).catch(e=>{this.logger.error("Failed to process a move to the trash in groups:",e)})}},this.ldap=e.ldap,this.base=this.config.ldap_group_base,this.cn=this.config.ldap_groups_main_attribute,this.base||(this.base=this.config.ldap_base,this.logger.warn(`LDAP group base is not defined, using "${this.base}"`)),!this.base)throw new Error("LDAP base is not defined, please set --ldap-group-base");if(this.config.ldap_group_base&&e.ldap.declareObjectClasses(this.base,this.config.group_class||[]),this.config.group_schema){const e=this.config.group_schema;n(e,{config:this.config}).then(e=>{this.schema=this.adaptSchema(e),this.logger.debug("Group schema loaded")}).catch(t=>this.logger.error(`Failed to load group schema from ${e}: ${t}`))}}adaptSchema(e){return e}api(e){e.get(`${this.config.api_prefix}/v1/ldap/groups`,d(async(e,t)=>{if(!r(e,t))return;const i={};if(e.query.match){if("string"!=typeof e.query.match)throw new v("Invalid match query");if("string"==typeof e.query.attribute&&e.query.attribute)i.filter=y(e.query.match,e.query.attribute);else if(/=/.test(e.query.match)){if(!/^[\w*=()&|, .@'-]+$/.test(e.query.match))throw new v("Invalid match query: contains forbidden characters");i.filter=e.query.match}else{const t=g(e.query.match);i.filter=`(${this.cn}=${t})`}}e.query.attributes&&"string"==typeof e.query.attributes&&(i.attributes=e.query.attributes.split(","));const s=await this.listGroups({...i,req:e});return a(t,this.hideNeverReturn(s))})),e.get(`${this.config.api_prefix}/v1/ldap/groups/:cn`,d(async(e,t)=>this.apiGet(e,t))),e.post(`${this.config.api_prefix}/v1/ldap/groups`,d(async(e,t)=>this.apiAdd(e,t,this.cn))),e.delete(`${this.config.api_prefix}/v1/ldap/groups/:cn`,d(async(e,t)=>this.apiDelete(e,t))),e.put(`${this.config.api_prefix}/v1/ldap/groups/:cn`,d(async(e,t)=>this.apiModify(e,t))),e.post(`${this.config.api_prefix}/v1/ldap/groups/:cn/members`,d(async(e,t)=>{const r=decodeURIComponent(e.params.cn);if(!r)throw new v("cn is required");const a=i(e,t,"member");if(!a)return;if((Array.isArray(a.member)?a.member:[a.member]).some(e=>"string"!=typeof e))throw new v("member must be a string or string array");await s(t,this.addMember.bind(this),r,a.member,e)})),e.delete(`${this.config.api_prefix}/v1/ldap/groups/:cn/members/:member`,d(async(e,t)=>{if(!r(e,t))return;const i=decodeURIComponent(e.params.cn),a=decodeURIComponent(e.params.member);if(!i||!a)throw new v("cn and member are required");await s(t,this.deleteMember.bind(this),i,a,e)})),this.config.ldap_organization_link_attribute&&this.config.ldap_organization_path_attribute&&e.post(`${this.config.api_prefix}/v1/ldap/groups/:cn/move`,d(async(e,t)=>{if(!r(e,t))return;const a=decodeURIComponent(e.params.cn);if(!a)throw new v("cn is required");const o=i(e,t,"targetOrgDn");if(o){if("string"!=typeof o.targetOrgDn)throw new v("targetOrgDn must be a string");await s(t,this.moveGroup.bind(this),a,o.targetOrgDn,e)}})),e.post(`${this.config.api_prefix}/v1/ldap/groups/:cn/rename`,d(async(e,t)=>{if(!r(e,t))return;const a=decodeURIComponent(e.params.cn);if(!a)throw new v("cn is required");const o=i(e,t,"newCn");if(o){if("string"!=typeof o.newCn)throw new v("newCn must be a string");await s(t,this.renameGroup.bind(this),a,o.newCn,e)}}))}isDummyMember(e){return m(e,this.config.group_dummy_user)}withoutDummyMember(e){const t=e.member;if(void 0===t)return e;const r=Array.isArray(t)?t:[t];return{...e,member:r.filter(e=>!this.isDummyMember(e))}}async apiGet(e,t){if(!r(e,t))return;const i=decodeURIComponent(e.params.cn);try{const r=/,/.test(i)?i:`${this.cn}=${p(i)},${this.base}`,s=await this.ldap.search({paged:!1,scope:"base"},r,e);if(0===s.searchEntries.length)throw new $("Group not found");t.json(this.hideNeverReturn(this.withoutDummyMember(s.searchEntries[0])))}catch(e){if(e.code&&32===e.code)throw new $("Group not found");throw e}}async apiAdd(e,t,...r){const a=i(e,t,...r);if(!a)return;G(this.schema,Object.keys(a),this.cn);const o=a[this.cn],n=a.member?a.member:[],h=Object.fromEntries(Object.entries(a).filter(([e,t])=>e!==this.cn&&"member"!==e&&void 0!==t&&!(!0===this.schema?.attributes[e]?.fixed)));await s(t,this.addGroup.bind(this),o,n,h,e)}async apiDelete(e,t){if(!r(e,t))return;const i=decodeURIComponent(e.params.cn);if(!i)throw new v("cn is required");await s(t,this.deleteGroup.bind(this),i,e)}async apiModify(e,r){const a=i(e,r);if(!a)return;t(a);const o=this.fixDn(decodeURIComponent(e.params.cn));if(!o)throw new v("cn is required");G(this.schema,C(a));const n=Object.fromEntries(Object.entries(a).filter(([e,t])=>!(!0===this.schema?.attributes[e]?.fixed)));await s(r,this.modifyGroup.bind(this),o,n,e)}async addGroup(e,t=[],r={},i){let s;new RegExp(`^${this.cn}=`).test(e)?(s=e,e=e.replace(new RegExp(`^${this.cn}=((?:\\\\.|[^,])+)(?:,.*)?$`),"$1")):(u(e,this.cn),s=`${this.cn}=${p(e)},${this.base}`),await this.validateMembers(s,t,i),await this.validateNewGroup(s,{objectClass:this.config.group_class,cn:e,member:t,...r});const a=[...this.config.group_class];!(r.mail||r.twakeMailboxType||r.twakeDepartmentLink||r.twakeDepartmentPath)||a.includes("twakeGroup")||a.includes("twakeStaticGroup")||a.push("twakeGroup");let o,n={objectClass:a,...this.config.group_default_attributes,[this.cn]:e,member:[this.config.group_dummy_user],...r};t.length&&n.member.push(...t),q(this.schema,n),[s,n]=await l(this.registeredHooks.ldapgroupadd,[s,n]);try{o=await this.ldap.add(s,n,i)}catch(e){if(e instanceof _)throw e;throw new Error(`Failed to add group ${s}: ${e}`)}return f(this.registeredHooks.ldapgroupadddone,[s,n]),o}async modifyGroup(e,t,r){let i=/,/.test(e)?e:`${this.cn}=${p(e)},${this.base}`;const s=this.opNumber();if([i,t]=await l(this.registeredHooks.ldapgroupmodify,[i,t,s]),t.add){if(t.add.member)throw new v("Use dedicated API to add members");if(t.add[this.cn])throw new v(`${this.cn} attribute is unique, cannot add`)}if(t.delete){if(t.delete instanceof Object){if(t.delete.member)throw new v("Use dedicated API to delete members");if(t.delete[this.cn])throw new v(`Cannot delete ${this.cn} attribute`)}if(Array.isArray(t.delete)){if(t.delete.includes("member"))throw new v("Use dedicated API to delete members");if(t.delete.includes(this.cn))throw new v(`Cannot delete ${this.cn} attribute`)}}if(t.replace){if(t.replace.member)throw new v("Use dedicated API to replace members");if(t.replace[this.cn])throw new v(`Use dedicated API to change ${this.cn} attribute`)}await this.validateChanges(i,t);const a=await this.ldap.modify(i,t,r);return f(this.registeredHooks.ldapgroupmodifydone,[i,t,s]),a}async renameGroup(e,t,r){/,/.test(e)||u(e,this.cn),/,/.test(t)||u(t,this.cn);let i=/,/.test(e)?e:`${this.cn}=${p(e)},${this.base}`,s=/,/.test(t)?t:`${this.cn}=${p(t)},${this.base}`;[i,s]=await l(this.registeredHooks.ldapgrouprename,[i,s]);const a=await this.ldap.rename(i,s,r);return f(this.registeredHooks.ldapgrouprenamedone,[i,s]),a}async deleteGroup(e,t){let r=/,/.test(e)?e:`${this.cn}=${p(e)},${this.base}`;r=await l(this.registeredHooks.ldapgroupdelete,r);const i=await this.ldap.delete(r,t);return f(this.registeredHooks.ldapgroupdeletedone,r),i}async addMember(e,t,r){const i=/,/.test(e)?e:`${this.cn}=${p(e)},${this.base}`;return Array.isArray(t)||(t=[t]),[e,t]=await l(this.registeredHooks.ldapgroupaddmember,[e,t]),await this.validateMembers(i,t,r),await this.ldap.modify(i,{add:{member:t}},r).catch(e=>{if(e instanceof _)throw e;throw new Error(`Failed to add member(s) to ${i}: ${e}`)})}async deleteMember(e,t,r){const i=/,/.test(e)?e:`${this.cn}=${p(e)},${this.base}`;if([e,t]=await l(this.registeredHooks.ldapgroupdeletemember,[e,t]),this.isDummyMember(t))throw new v("Cannot delete dummy member from group");return await this.removeMember(i,t,r).catch(e=>{if(16===o(e))throw new $(`${t} is not a member of ${i}`);if(e instanceof _)throw e;throw new Error(`Failed to delete member ${t} from ${i}: ${e}`)})}async removeMember(e,t,r){return await this.ldap.modify(e,{delete:{member:t}},r).catch(i=>{if(65!==o(i))throw i;const s=this.config.group_dummy_user;if(!s)throw new A(`Cannot remove ${t}, the last member of ${e}: a group needs at least one member`);return this.ldap.modify(e,{add:{member:s},delete:{member:t}},r)})}async deleteMemberFromAll(e){const t=await this.ldap.search({filter:`member=${g(e)}`,paged:!1,attributes:[this.cn]},this.base).catch(e=>{throw new Error(`Failed to search groups from ${this.base}: ${e}`)});await Promise.all(t.searchEntries.map(t=>this.removeMember(t.dn,e).catch(r=>{const i=o(r);16!==i&&32!==i&&this.logger.error(`Failed to remove ${e} from group ${t.dn}:`,r)})))}async moveGroup(e,t,r){const i=this.config.ldap_organization_link_attribute,s=this.config.ldap_organization_path_attribute,a=/,/.test(e)?e:`${this.cn}=${p(e)},${this.base}`;let n;try{n=await this.ldap.search({paged:!1,scope:"base"},a)}catch(e){if(32===o(e))throw new $(`Group ${a} not found`);throw e}if(0===n.searchEntries.length)throw new $(`Group ${a} not found`);const h=n.searchEntries[0],c=w(h[i]);if(!c)throw new v(`Group ${a} does not have ${i} attribute and cannot be moved`);if(c===t)throw new v("Group is already in the target organization");let d;try{d=await this.ldap.search({paged:!1,scope:"base",attributes:[s,"ou","o"]},t)}catch(e){const r=o(e);if(32===r)throw new $(`Target organization ${t} not found`);if(34===r)throw new v(`${t} is not a valid DN`);throw new Error(`Target organization ${t} not found: ${e instanceof Error&&e.message?e.message:String(e)}`)}if(0===d.searchEntries.length)throw new $(`Target organization ${t} not found`);const m=d.searchEntries[0][s];if(!m)throw new Error(`Target organization ${t} does not have ${s} attribute`);return await this.ldap.modify(a,{replace:{[i]:t,[s]:m}},r),this.logger.info(`Group ${a} moved from ${c} to ${t}`),{success:!0}}async listGroups({filter:e,attributes:t,req:r}={}){const i=await this.ldap.search({filter:e||"(objectClass=*)",attributes:t||[this.cn,"member"],paged:!0},this.base,r).catch(e=>{throw new Error(`Failed to list groups from ${this.base}: ${e}`)});let s={};for await(const e of i)for(const t of e.searchEntries){const e=t[this.cn];if(Array.isArray(e)&&0===e.length)continue;e&&(s[e]=this.withoutDummyMember(t))}return s=await l(this.registeredHooks._ldapgrouplist,s),s}async searchGroupsByName(e,t=!1,r=[this.cn,"member"],i){const s=g(e),a=t?`(${this.cn}=*${s}*)`:`(${this.cn}=${s})`;return await this.listGroups({filter:a,attributes:r,req:i})}fixDn(e){return!!e&&(/,/.test(e)?e:`${this.cn}=${p(e)},${this.base}`)}async validateMembers(e,t,r){[e,t]=await l(this.server.hooks.ldapgroupvalidatemembers,[e,t]);const i=Boolean(this.config.groups_allow_unexistent_members);if((!i||r)&&t&&t.length)try{await Promise.all(t.map(e=>this.server.ldap.queryLimit(async()=>{let t;try{t=await this.ldap.search({paged:!1,scope:"base"},e,r)}catch(t){if(b(t))throw t;if(i)return;const r=o(t);if(32===r)throw new v(`Member ${e} not found`);if(34===r)throw new v(`Member ${e} is not a valid DN`);throw new Error(`Member ${e} not found: ${t}`)}if(r&&!t.searchEntries?.length)throw new v(`Member ${e} not found`)})))}catch(e){if(e instanceof _||b(e))throw e;throw new Error(`Failed to find member(s): ${e}`)}}async validateNewGroup(e,t){if(!this.schema)return q(void 0,t),!0;[e,t]=await l(this.server.hooks.ldapgroupvalidatenew,[e,t]);for(const[e,r]of Object.entries(t))if(!await this._validateOneChange(e,r))throw new v(M(e,this.schema.attributes[e]));const r=E(this.schema,t,this.server.loadedPlugins);if(r)throw new v(`Attribute "${r}" is required`);return q(this.schema,t),!0}async validateChanges(e,t){if(!this.schema)return j(void 0,t),!0;if([e,t]=await l(this.server.hooks.ldapgroupvalidatechanges,[e,t]),t.add)for(const[e,r]of Object.entries(t.add))if(!await this._validateOneChange(e,r))throw new v(M(e,this.schema.attributes[e]));if(t.replace)for(const[e,r]of Object.entries(t.replace))if(!await this._validateOneChange(e,r))throw new v(M(e,this.schema.attributes[e]));if(t.delete&&t.delete instanceof Object)for(const e of Array.isArray(t.delete)?t.delete:Object.keys(t.delete))await this._validateOneChange(e,null);return j(this.schema,t),!0}async _validateOneChange(e,t){if(!this.schema)return!0;const r=this.schema.attributes[e];if(!r){if(this.schema.strict)throw new v(`Attribute "${e}" is not allowed`);return!0}if(null==t||Array.isArray(t)&&0===t.length){if(r.required)throw new v(`Attribute "${e}" is required`);return!0}if("array"===r.type){if(!Array.isArray(t))throw new v(`Attribute "${e}" must be an array`);if(!r.items)throw new Error(`Schema error: no item for array ${e}`);if("array"===r.items.type)throw new Error(`Schema error: array of array not supported for ${e}`);if(r.items.test&&"pointer"!==r.items.type)for(const i of t)if(typeof i!==r.items.type)throw new v(`Attribute "${e}" must be of type ${r.items.type}`)}else if("pointer"!==r.type&&typeof t!==r.type)return!1;if(await k(e,r,t,async e=>(await this.ldap.search({paged:!1,scope:"base",attributes:["dn"]},e)).searchEntries.length>0),!O(r,t))throw new v(M(e,r));return!0}getConfigApiData(){const e=this.config.api_prefix||"/api",t=this.server.loadedPlugins.static&&this.config.group_schema?h(this.config,this.config.group_schema):void 0;return{enabled:!0,base:this.base||"",mainAttribute:this.cn||"cn",objectClass:this.config.group_class||["top","groupOfNames"],schema:this.schema,schemaUrl:t,endpoints:{list:`${e}/v1/ldap/groups`,get:`${e}/v1/ldap/groups/:id`,create:`${e}/v1/ldap/groups`,update:`${e}/v1/ldap/groups/:id`,delete:`${e}/v1/ldap/groups/:id`,addMember:`${e}/v1/ldap/groups/:id/members`,removeMember:`${e}/v1/ldap/groups/:id/members/:memberId`,rename:`${e}/v1/ldap/groups/:id/rename`,move:`${e}/v1/ldap/groups/:id/move`}}}}export{LdapGroups as default};
@@ -1 +1 @@
1
- import{isDeepStrictEqual as e}from"node:util";import i from"../../abstract/plugin.js";import{asyncHandler as t,normalizeDn as s,escapeLdapFilter as a,validateDnValue as o,escapeDnValue as r}from"../../lib/utils.js";import{jsonBody as n}from"../../lib/expressFormatedResponses.js";import{extractLdapCode as c}from"../../lib/ldapCodes.js";import{NotFoundError as l,ConflictError as d,BadRequestError as p}from"../../lib/errors.js";const u=["pwdChangedTime","pwdAccountLockedTime","pwdFailureTime","pwdGraceUseTime","pwdReset","pwdPolicySubentry"],w={pwdMinAge:"integer",pwdMaxAge:"integer",pwdInHistory:"integer",pwdCheckQuality:"integer",pwdMinLength:"integer",pwdMaxLength:"integer",pwdExpireWarning:"integer",pwdGraceAuthNLimit:"integer",pwdGraceExpiry:"integer",pwdLockout:"boolean",pwdLockoutDuration:"integer",pwdMaxFailure:"integer",pwdFailureCountInterval:"integer",pwdMustChange:"boolean",pwdAllowUserChange:"boolean",pwdSafeModify:"boolean",pwdMinDelay:"integer",pwdMaxDelay:"integer",pwdMaxIdle:"integer",pwdMaxRecordedFailure:"integer",pwdCheckModuleArg:"text",pwdUseCheckModule:"boolean"},h=["pwdCheckModuleArg","pwdUseCheckModule"],y=[...Object.keys(w),"pwdAttribute","pwdCheckModule"],isWritablePolicyAttribute=e=>Object.prototype.hasOwnProperty.call(w,e),values=e=>(Array.isArray(e)?e:void 0===e?[]:[e]).map(e=>Buffer.isBuffer(e)?e.toString():String(e)).filter(e=>""!==e),first=e=>values(e)[0],userEntry=e=>({dn:e.dn,userPassword:first(e.userPassword),uid:first(e.uid),cn:first(e.cn),displayName:first(e.displayName),mail:first(e.mail),pwdChangedTime:first(e.pwdChangedTime),pwdAccountLockedTime:first(e.pwdAccountLockedTime),pwdFailureTime:values(e.pwdFailureTime),pwdGraceUseTime:values(e.pwdGraceUseTime),pwdReset:first(e.pwdReset),pwdPolicySubentry:first(e.pwdPolicySubentry)}),typedPolicy=e=>{const i={};for(const t of y){const s=values(e[t]);if(0!==s.length)switch(w[t]){case"integer":{const e=parseInt(s[0],10);isNaN(e)||(i[t]=e);break}case"boolean":i[t]="TRUE"===s[0];break;default:i[t]=1===s.length?s[0]:s}}return i},g=/[!@#$%^&*()_+\-=[\]{}|;:'",.<>?/]/;class PasswordPolicy extends i{constructor(){super(...arguments),this.name="ldapPasswordPolicy",this.roles=["api","configurable"],this.policyCache=new Map,this.discoveredDefault=null,this.warnedAmbiguousDefault=!1,this.warnedMissingBase=!1,this.POLICY_CACHE_TTL=6e4}api(e){const i=`${this.config.api_prefix}/v1`;e.get(`${i}/password-policy`,t(async(e,i)=>{const t=await this.readPolicyConfig();if(t.dn)try{await this.server.ldap.search({filter:"(objectClass=*)",scope:"base",attributes:["1.1"],paged:!1},t.dn,e)}catch(e){if(32!==c(e))throw e;return this.forgetPolicies(),void i.json({})}i.json(t)})),e.get(`${i}/users/:id/password-status`,t(async(e,i)=>{const t=this.param(e,"id"),s=await this.getPasswordStatus(t,e);i.json(s)})),e.post(`${i}/users/:id/unlock`,t(async(e,i)=>{const t=this.param(e,"id");await this.unlockAccount(t,e),i.json({success:!0,message:"Account unlocked"})})),e.get(`${i}/password-policy/expiring-soon`,t(async(e,i)=>{const t=parseInt(e.query.days)||this.config.ppolicy_warn_days||14,s=await this.findExpiringPasswords(t,e);i.json({warningDays:t,users:s})})),e.get(`${i}/password-policy/locked-accounts`,t(async(e,i)=>{const t=await this.findLockedAccounts(e);i.json({accounts:t})})),e.get(`${i}/password-policies`,t(async(e,i)=>{i.json({policies:await this.listPolicies(e)})})),e.post(`${i}/password-policies`,t(async(e,i)=>{const t=n(e,i);if(!t)return;const s=await this.createPolicy(t,e);i.status(201).json(s)})),e.get(`${i}/password-policies/:cn`,t(async(e,i)=>{const t=await this.findPolicy(this.param(e,"cn"),e);i.json(await this.toPolicyRecord(t))})),e.patch(`${i}/password-policies/:cn`,t(async(e,i)=>{const t=n(e,i);if(!t)return;const s=await this.updatePolicy(this.param(e,"cn"),t,e);i.json(s)})),e.delete(`${i}/password-policies/:cn`,t(async(e,i)=>{await this.deletePolicy(this.param(e,"cn"),e),i.json({success:!0})})),e.put(`${i}/users/:id/password-policy`,t(async(e,i)=>{const t=n(e,i);if(!t)return;const{policy:s}=t;if(null!==s&&("string"!=typeof s||!s))throw new p("policy must be a policy name or null");i.json(await this.assignPolicy(this.param(e,"id"),s,e))})),this.config.ppolicy_validate_complexity&&e.post(`${i}/password/validate`,t(async(e,i)=>{const t=n(e,i);if(!t)return;const{password:s}=t;if(!s)throw new p("password required");const a=this.validateComplexity(s);i.json(a)}))}async getPasswordStatus(e,i){const t=this.resolveUserDn(e),s=await this.readUser(t,e,["*",...u],i),a=await this.policyFor(s);return this.calculateStatus(t,s,a)}async readUser(e,i,t,s){try{const a=await this.server.ldap.search({filter:"(objectClass=*)",scope:"base",attributes:t,paged:!1},e,s);if(!a.searchEntries||0===a.searchEntries.length)throw new l(`User not found: ${i}`);return userEntry(a.searchEntries[0])}catch(e){if(e instanceof Error&&("NoSuchObjectError"===e.name||e.message.includes("0x20")||e.message.includes("Code: 32")))throw new l(`User not found: ${i}`);throw e}}calculateStatus(e,i,t){const s=Date.now(),a=this.parseGeneralizedTime(i.pwdChangedTime);let o=null,r=!1,n=null;t.pwdMaxAge&&a&&(o=new Date(a.getTime()+1e3*t.pwdMaxAge),r=s>o.getTime(),n=Math.ceil((o.getTime()-s)/864e5));let c=!1;const l=this.config.ppolicy_warn_days||14;null!==n&&n>0&&(c=n<=l);const d=i.pwdAccountLockedTime;let p=!!d;if(p&&!("000001010000Z"===d)&&t.pwdLockoutDuration){const e=this.parseGeneralizedTime(d);if(e){s>new Date(e.getTime()+1e3*t.pwdLockoutDuration).getTime()&&(p=!1)}}const u=i.pwdFailureTime,w=Array.isArray(u)?u.length:0,h=i.pwdGraceUseTime,y=Array.isArray(h)?h.length:0,g="TRUE"===i.pwdReset;return{dn:e,passwordSet:!!i.userPassword,lastChanged:a?.toISOString()||null,expiresAt:o?.toISOString()||null,daysUntilExpiration:n,isExpired:r,isExpiringSoon:c,mustChange:g,isLocked:p,lockedAt:d&&this.parseGeneralizedTime(d)?.toISOString()||null,failureCount:w,graceLoginsUsed:y,policy:t.dn??null}}async unlockAccount(e,i){const t=this.resolveUserDn(e),s=await this.server.ldap.search({filter:"(objectClass=*)",scope:"base",attributes:["pwdAccountLockedTime","pwdFailureTime"],paged:!1},t,i);if(!s.searchEntries||0===s.searchEntries.length)throw new l(`User not found: ${e}`);const a=userEntry(s.searchEntries[0]),o={};a.pwdAccountLockedTime&&(o.pwdAccountLockedTime=[]),a.pwdFailureTime?.length&&(o.pwdFailureTime=[]),Object.keys(o).length>0?(await this.server.ldap.modify(t,{delete:o},i),this.logger.info(`Account unlocked: ${t}`)):this.logger.info(`Account was not locked: ${t}`)}async findExpiringPasswords(e,i){const t=this.config.ldap_users_base||this.config.ldap_base,s=await this.server.ldap.search({filter:"(pwdChangedTime=*)",scope:"sub",attributes:["uid","cn","displayName","mail","pwdChangedTime","pwdPolicySubentry"],paged:!1},t,i),a=Date.now(),o=24*e*60*60*1e3,r=[];for(const e of s.searchEntries){const i=userEntry(e),t=this.parseGeneralizedTime(i.pwdChangedTime);if(!t)continue;const s=await this.policyFor(i);if(!s.pwdMaxAge)continue;const n=new Date(t.getTime()+1e3*s.pwdMaxAge),c=n.getTime()-a;c>0&&c<o&&r.push({dn:i.dn,uid:i.uid,displayName:i.displayName||i.cn,mail:i.mail,expiresAt:n.toISOString(),daysUntilExpiration:Math.ceil(c/864e5)})}return r.sort((e,i)=>e.daysUntilExpiration-i.daysUntilExpiration)}async findLockedAccounts(e){const i=this.config.ldap_users_base||this.config.ldap_base;return(await this.server.ldap.search({filter:"(pwdAccountLockedTime=*)",scope:"sub",attributes:["uid","cn","displayName","mail","pwdAccountLockedTime","pwdFailureTime"],paged:!1},i,e)).searchEntries.map(e=>{const i=userEntry(e);return{dn:i.dn,uid:i.uid,displayName:i.displayName||i.cn,mail:i.mail,lockedAt:this.parseGeneralizedTime(i.pwdAccountLockedTime)?.toISOString(),failureCount:Array.isArray(i.pwdFailureTime)?i.pwdFailureTime.length:0}})}async policyFor(e){if(e.pwdPolicySubentry){const i=await this.readPolicyAt(e.pwdPolicySubentry);if(i)return i}return this.readPolicyConfig()}async readPolicyConfig(){const e=this.config.ppolicy_default_dn||await this.discoverDefaultDn();return(e?await this.readPolicyAt(e):null)??{}}async discoverDefaultDn(){if(this.discoveredDefault&&Date.now()-this.discoveredDefault.time<this.POLICY_CACHE_TTL)return this.discoveredDefault.dn;let e=null;try{const i=(await this.server.ldap.search({filter:"(objectClass=pwdPolicy)",scope:"sub",attributes:["1.1"],paged:!1},this.policyBase())).searchEntries.map(e=>e.dn);1===i.length?e=i[0]:i.length>1&&!this.warnedAmbiguousDefault&&(this.warnedAmbiguousDefault=!0,this.logger.warn(`${i.length} password policies found and no --ppolicy-default-dn: accounts without pwdPolicySubentry are reported without a policy`))}catch(e){this.logger.debug(`No ppolicy found: ${e}`)}return this.discoveredDefault={dn:e,time:Date.now()},e}async readPolicyAt(e){const i=s(e),t=this.policyCache.get(i);if(t&&Date.now()-t.time<this.POLICY_CACHE_TTL)return t.policy;let a,o=null;try{const i=await this.server.ldap.search({filter:"(objectClass=pwdPolicy)",scope:"base",attributes:y,paged:!1},e);i.searchEntries.length>0&&(o=((e,i)=>{const t=typedPolicy(e);return{dn:i,pwdMaxAge:t.pwdMaxAge,pwdMinAge:t.pwdMinAge,pwdInHistory:t.pwdInHistory,pwdCheckQuality:t.pwdCheckQuality,pwdMinLength:t.pwdMinLength,pwdMaxFailure:t.pwdMaxFailure,pwdLockout:!0===t.pwdLockout,pwdLockoutDuration:t.pwdLockoutDuration,pwdGraceAuthNLimit:t.pwdGraceAuthNLimit,pwdExpireWarning:t.pwdExpireWarning,pwdMustChange:!0===t.pwdMustChange,pwdAllowUserChange:!1!==t.pwdAllowUserChange}})(i.searchEntries[0],e))}catch(e){a=String(e)}const r=this.config.ppolicy_default_dn;return!o&&r&&s(r)===i?this.logger.warn(`--ppolicy-default-dn ${e} holds no password policy${a?`: ${a}`:""}`):a&&this.logger.debug(`Failed to read ppolicy from ${e}: ${a}`),this.policyCache.set(i,{policy:o,time:Date.now()}),o}forgetPolicies(){this.policyCache.clear(),this.discoveredDefault=null}policyBase(){return this.config.ppolicy_base||this.config.ldap_base}async searchPolicies(e,i){try{return(await this.server.ldap.search({filter:`(&(objectClass=pwdPolicy)${e})`,scope:"sub",attributes:["objectClass","cn",...y],paged:!1},this.policyBase(),i)).searchEntries}catch(e){if(32!==c(e))throw e;return this.warnedMissingBase||(this.warnedMissingBase=!0,this.logger.warn(`${this.policyBase()} does not exist: no password policy is found there, check --ppolicy-base`)),[]}}async findPolicy(e,i){const t=await this.searchPolicies(`(cn=${a(e)})`,i);if(0===t.length)throw new l(`No password policy named ${e}`);if(t.length>1)throw new d(`Several password policies are named ${e}`);return t[0]}async listPolicies(e){const i=[];for(const t of await this.searchPolicies("",e))i.push(await this.toPolicyRecord(t));return i.sort((e,i)=>e.cn.localeCompare(i.cn))}async toPolicyRecord(e){const i=(await this.readPolicyConfig()).dn;return{...{dn:e.dn,cn:values(e.cn)[0]??"",isDefault:!!i&&s(i)===s(e.dn)},...typedPolicy(e)}}policyValue(e,i){switch(w[e]){case"integer":if("number"!=typeof i||!Number.isInteger(i)||i<0||"pwdCheckQuality"===e&&i>2)throw new p("pwdCheckQuality"===e?"pwdCheckQuality must be 0, 1 or 2":`${e} must be a non-negative integer`);return String(i);case"boolean":if("boolean"!=typeof i)throw new p(`${e} must be a boolean`);return i?"TRUE":"FALSE";default:if("string"!=typeof i||""===i)throw new p(`${e} must be a non-empty string`);return i}}async createPolicy(e,i){const{cn:t,...s}=e;if("string"!=typeof t)throw new p("cn required");try{o(t,"cn")}catch(e){throw new p(e.message)}const a=await this.findPolicy(t,i).catch(e=>{if(e instanceof l)return null;throw e});if(a)throw new d(`A password policy is named ${t}: ${a.dn}`);const n=["top","person","pwdPolicy"],u={objectClass:n,cn:t,sn:t,pwdAttribute:"userPassword"};for(const[e,i]of Object.entries(s)){if(!isWritablePolicyAttribute(e))throw new p(`${e} cannot be written`);null!==i&&(u[e]=this.policyValue(e,i))}h.some(e=>e in u)&&n.push("pwdPolicyChecker");try{await this.server.ldap.add(`cn=${r(t)},${this.policyBase()}`,u,i)}catch(e){if(32!==c(e))throw e;throw new l(`${this.policyBase()} does not exist: create it or change --ppolicy-base`)}return this.forgetPolicies(),this.toPolicyRecord(await this.findPolicy(t,i))}async updatePolicy(i,t,a){const o=await this.findPolicy(i,a),r=await this.toPolicyRecord(o),n={},c=[];for(const[i,a]of Object.entries(t)){if(!isWritablePolicyAttribute(i)){if("dn"===i?"string"==typeof a&&s(a)===s(r.dn):Object.prototype.hasOwnProperty.call(r,i)&&e(r[i],a))continue;throw new p(`${i} cannot be written`)}null!==a?n[i]=this.policyValue(i,a):void 0!==r[i]&&c.push(i)}const l={};if(Object.keys(n).length>0&&(l.replace=n),c.length>0&&(l.delete=c),!l.replace&&!l.delete)return r;const d=values(o.objectClass).map(e=>e.toLowerCase());return h.some(e=>e in n)&&!d.includes("pwdpolicychecker")&&(l.add={objectClass:"pwdPolicyChecker"}),await this.server.ldap.modify(o.dn,l,a),this.forgetPolicies(),this.toPolicyRecord(await this.findPolicy(i,a))}async deletePolicy(e,i){const t=await this.findPolicy(e,i),o=(await this.readPolicyConfig()).dn;if(o&&s(o)===s(t.dn))throw new d(`${t.dn} is the default password policy`);if((await this.server.ldap.search({filter:`(pwdPolicySubentry=${a(t.dn)})`,scope:"sub",attributes:["1.1"],sizeLimit:1,paged:!1},this.config.ldap_base)).searchEntries.length>0)throw new d(`${t.dn} is still applied to at least one entry`);await this.server.ldap.delete(t.dn,i),this.forgetPolicies()}async assignPolicy(e,i,t){const s=this.resolveUserDn(e);if(null===i){try{await this.server.ldap.modify(s,{delete:["pwdPolicySubentry"]},t)}catch(e){if(16!==c(e))throw e}return{dn:s,policy:null}}const a=await this.findPolicy(i,t);return await this.server.ldap.modify(s,{replace:{pwdPolicySubentry:a.dn}},t),{dn:s,policy:a.dn}}param(e,i){const t=e.params[i];return Array.isArray(t)?t[0]:t}parseGeneralizedTime(e){if(!e)return null;const i=e.match(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/);if(!i)return null;const[,t,s,a,o,r,n]=i;return new Date(Date.UTC(+t,+s-1,+a,+o,+r,+n))}validateComplexity(e){const i=[],t=this.config,s=t.ppolicy_min_length||12;return e.length<s&&i.push(`Minimum ${s} characters required`),t.ppolicy_require_uppercase&&!/[A-Z]/.test(e)&&i.push("At least one uppercase letter required"),t.ppolicy_require_lowercase&&!/[a-z]/.test(e)&&i.push("At least one lowercase letter required"),t.ppolicy_require_digit&&!/\d/.test(e)&&i.push("At least one digit required"),t.ppolicy_require_special&&!g.test(e)&&i.push("At least one special character required"),{valid:0===i.length,errors:i}}resolveUserDn(e){if(e.includes("="))return e;const i=this.config.ldap_users_base||this.config.ldap_base;return`${this.config.ldap_user_main_attribute||"uid"}=${r(e)},${i}`}getConfigApiData(){const e=this.config.api_prefix||"/api",i=!!this.config.ppolicy_validate_complexity;return{name:this.name,enabled:!0,endpoints:{getPolicy:`${e}/v1/password-policy`,getUserStatus:`${e}/v1/users/:id/password-status`,unlockUser:`${e}/v1/users/:id/unlock`,getExpiringSoon:`${e}/v1/password-policy/expiring-soon`,getLockedAccounts:`${e}/v1/password-policy/locked-accounts`,listPolicies:`${e}/v1/password-policies`,policy:`${e}/v1/password-policies/:cn`,assignPolicy:`${e}/v1/users/:id/password-policy`,validatePassword:i?`${e}/v1/password/validate`:void 0},config:{warnDays:this.config.ppolicy_warn_days||14,validateComplexity:i,...i&&{complexityRules:{minLength:this.config.ppolicy_min_length||12,requireUppercase:this.config.ppolicy_require_uppercase??!0,requireLowercase:this.config.ppolicy_require_lowercase??!0,requireDigit:this.config.ppolicy_require_digit??!0,requireSpecial:this.config.ppolicy_require_special??!0}}}}}}export{PasswordPolicy as default};
1
+ import{isDeepStrictEqual as e}from"node:util";import t from"../../abstract/plugin.js";import{asyncHandler as i,normalizeDn as s,escapeLdapFilter as a,validateDnValue as r,escapeDnValue as o}from"../../lib/utils.js";import{jsonBody as n}from"../../lib/expressFormatedResponses.js";import{extractLdapCode as c}from"../../lib/ldapCodes.js";import{NotFoundError as l,ConflictError as d,BadRequestError as p}from"../../lib/errors.js";const u=["pwdChangedTime","pwdAccountLockedTime","pwdFailureTime","pwdGraceUseTime","pwdReset","pwdPolicySubentry"],h={pwdMinAge:"integer",pwdMaxAge:"integer",pwdInHistory:"integer",pwdCheckQuality:"integer",pwdMinLength:"integer",pwdMaxLength:"integer",pwdExpireWarning:"integer",pwdGraceAuthNLimit:"integer",pwdGraceExpiry:"integer",pwdLockout:"boolean",pwdLockoutDuration:"integer",pwdMaxFailure:"integer",pwdFailureCountInterval:"integer",pwdMustChange:"boolean",pwdAllowUserChange:"boolean",pwdSafeModify:"boolean",pwdMinDelay:"integer",pwdMaxDelay:"integer",pwdMaxIdle:"integer",pwdMaxRecordedFailure:"integer",pwdCheckModuleArg:"text",pwdUseCheckModule:"boolean"},w=["pwdCheckModuleArg","pwdUseCheckModule"],y=[...Object.keys(h),"pwdAttribute","pwdCheckModule"],isWritablePolicyAttribute=e=>Object.prototype.hasOwnProperty.call(h,e),values=e=>(Array.isArray(e)?e:void 0===e?[]:[e]).map(e=>Buffer.isBuffer(e)?e.toString():String(e)).filter(e=>""!==e),first=e=>values(e)[0],userEntry=e=>({dn:e.dn,userPassword:first(e.userPassword),uid:first(e.uid),cn:first(e.cn),displayName:first(e.displayName),mail:first(e.mail),pwdChangedTime:first(e.pwdChangedTime),pwdAccountLockedTime:first(e.pwdAccountLockedTime),pwdFailureTime:values(e.pwdFailureTime),pwdGraceUseTime:values(e.pwdGraceUseTime),pwdReset:first(e.pwdReset),pwdPolicySubentry:first(e.pwdPolicySubentry)}),typedPolicy=e=>{const t={};for(const i of y){const s=values(e[i]);if(0!==s.length)switch(h[i]){case"integer":{const e=parseInt(s[0],10);isNaN(e)||(t[i]=e);break}case"boolean":t[i]="TRUE"===s[0];break;default:t[i]=1===s.length?s[0]:s}}return t},g=/[!@#$%^&*()_+\-=[\]{}|;:'",.<>?/]/;class PasswordPolicy extends t{constructor(){super(...arguments),this.name="ldapPasswordPolicy",this.roles=["api","configurable"],this.policyCache=new Map,this.discoveredDefault=null,this.warnedAmbiguousDefault=!1,this.warnedMissingBase=!1,this.warnedUnreadableSchema=!1,this.POLICY_CACHE_TTL=6e4}api(e){const t=`${this.config.api_prefix}/v1`;e.get(`${t}/password-policy`,i(async(e,t)=>{const i=await this.readPolicyConfig();if(i.dn)try{await this.server.ldap.search({filter:"(objectClass=*)",scope:"base",attributes:["1.1"],paged:!1},i.dn,e)}catch(e){if(32!==c(e))throw e;return this.forgetPolicies(),void t.json({})}t.json(i)})),e.get(`${t}/users/:id/password-status`,i(async(e,t)=>{const i=this.param(e,"id"),s=await this.getPasswordStatus(i,e);t.json(s)})),e.post(`${t}/users/:id/unlock`,i(async(e,t)=>{const i=this.param(e,"id");await this.unlockAccount(i,e),t.json({success:!0,message:"Account unlocked"})})),e.get(`${t}/password-policy/expiring-soon`,i(async(e,t)=>{const i=parseInt(e.query.days)||this.config.ppolicy_warn_days||14,s=await this.findExpiringPasswords(i,e);t.json({warningDays:i,users:s})})),e.get(`${t}/password-policy/locked-accounts`,i(async(e,t)=>{const i=await this.findLockedAccounts(e);t.json({accounts:i})})),e.get(`${t}/password-policies`,i(async(e,t)=>{t.json({policies:await this.listPolicies(e),attributes:await this.definedAttributes()})})),e.post(`${t}/password-policies`,i(async(e,t)=>{const i=n(e,t);if(!i)return;const s=await this.createPolicy(i,e);t.status(201).json(s)})),e.get(`${t}/password-policies/:cn`,i(async(e,t)=>{const i=await this.findPolicy(this.param(e,"cn"),e);t.json(await this.toPolicyRecord(i))})),e.patch(`${t}/password-policies/:cn`,i(async(e,t)=>{const i=n(e,t);if(!i)return;const s=await this.updatePolicy(this.param(e,"cn"),i,e);t.json(s)})),e.delete(`${t}/password-policies/:cn`,i(async(e,t)=>{await this.deletePolicy(this.param(e,"cn"),e),t.json({success:!0})})),e.put(`${t}/users/:id/password-policy`,i(async(e,t)=>{const i=n(e,t);if(!i)return;const{policy:s}=i;if(null!==s&&("string"!=typeof s||!s))throw new p("policy must be a policy name or null");t.json(await this.assignPolicy(this.param(e,"id"),s,e))})),this.config.ppolicy_validate_complexity&&e.post(`${t}/password/validate`,i(async(e,t)=>{const i=n(e,t);if(!i)return;const{password:s}=i;if(!s)throw new p("password required");const a=this.validateComplexity(s);t.json(a)}))}async getPasswordStatus(e,t){const i=this.resolveUserDn(e),s=await this.readUser(i,e,["*",...u],t),a=await this.policyFor(s);return this.calculateStatus(i,s,a)}async readUser(e,t,i,s){try{const a=await this.server.ldap.search({filter:"(objectClass=*)",scope:"base",attributes:i,paged:!1},e,s);if(!a.searchEntries||0===a.searchEntries.length)throw new l(`User not found: ${t}`);return userEntry(a.searchEntries[0])}catch(e){if(e instanceof Error&&("NoSuchObjectError"===e.name||e.message.includes("0x20")||e.message.includes("Code: 32")))throw new l(`User not found: ${t}`);throw e}}calculateStatus(e,t,i){const s=Date.now(),a=this.parseGeneralizedTime(t.pwdChangedTime);let r=null,o=!1,n=null;i.pwdMaxAge&&a&&(r=new Date(a.getTime()+1e3*i.pwdMaxAge),o=s>r.getTime(),n=Math.ceil((r.getTime()-s)/864e5));let c=!1;const l=this.config.ppolicy_warn_days||14;null!==n&&n>0&&(c=n<=l);const d=t.pwdAccountLockedTime;let p=!!d;if(p&&!("000001010000Z"===d)&&i.pwdLockoutDuration){const e=this.parseGeneralizedTime(d);if(e){s>new Date(e.getTime()+1e3*i.pwdLockoutDuration).getTime()&&(p=!1)}}const u=t.pwdFailureTime,h=Array.isArray(u)?u.length:0,w=t.pwdGraceUseTime,y=Array.isArray(w)?w.length:0,g="TRUE"===t.pwdReset;return{dn:e,passwordSet:!!t.userPassword,lastChanged:a?.toISOString()||null,expiresAt:r?.toISOString()||null,daysUntilExpiration:n,isExpired:o,isExpiringSoon:c,mustChange:g,isLocked:p,lockedAt:d&&this.parseGeneralizedTime(d)?.toISOString()||null,failureCount:h,graceLoginsUsed:y,policy:i.dn??null}}async unlockAccount(e,t){const i=this.resolveUserDn(e),s=await this.server.ldap.search({filter:"(objectClass=*)",scope:"base",attributes:["pwdAccountLockedTime","pwdFailureTime"],paged:!1},i,t);if(!s.searchEntries||0===s.searchEntries.length)throw new l(`User not found: ${e}`);const a=userEntry(s.searchEntries[0]),r={};a.pwdAccountLockedTime&&(r.pwdAccountLockedTime=[]),a.pwdFailureTime?.length&&(r.pwdFailureTime=[]),Object.keys(r).length>0?(await this.server.ldap.modify(i,{delete:r},t),this.logger.info(`Account unlocked: ${i}`)):this.logger.info(`Account was not locked: ${i}`)}async findExpiringPasswords(e,t){const i=this.config.ldap_users_base||this.config.ldap_base,s=await this.server.ldap.search({filter:"(pwdChangedTime=*)",scope:"sub",attributes:["uid","cn","displayName","mail","pwdChangedTime","pwdPolicySubentry"],paged:!1},i,t),a=Date.now(),r=24*e*60*60*1e3,o=[];for(const e of s.searchEntries){const t=userEntry(e),i=this.parseGeneralizedTime(t.pwdChangedTime);if(!i)continue;const s=await this.policyFor(t);if(!s.pwdMaxAge)continue;const n=new Date(i.getTime()+1e3*s.pwdMaxAge),c=n.getTime()-a;c>0&&c<r&&o.push({dn:t.dn,uid:t.uid,displayName:t.displayName||t.cn,mail:t.mail,expiresAt:n.toISOString(),daysUntilExpiration:Math.ceil(c/864e5)})}return o.sort((e,t)=>e.daysUntilExpiration-t.daysUntilExpiration)}async findLockedAccounts(e){const t=this.config.ldap_users_base||this.config.ldap_base;return(await this.server.ldap.search({filter:"(pwdAccountLockedTime=*)",scope:"sub",attributes:["uid","cn","displayName","mail","pwdAccountLockedTime","pwdFailureTime"],paged:!1},t,e)).searchEntries.map(e=>{const t=userEntry(e);return{dn:t.dn,uid:t.uid,displayName:t.displayName||t.cn,mail:t.mail,lockedAt:this.parseGeneralizedTime(t.pwdAccountLockedTime)?.toISOString(),failureCount:Array.isArray(t.pwdFailureTime)?t.pwdFailureTime.length:0}})}async policyFor(e){if(e.pwdPolicySubentry){const t=await this.readPolicyAt(e.pwdPolicySubentry);if(t)return t}return this.readPolicyConfig()}async readPolicyConfig(){const e=this.config.ppolicy_default_dn||await this.discoverDefaultDn();return(e?await this.readPolicyAt(e):null)??{}}async discoverDefaultDn(){if(this.discoveredDefault&&Date.now()-this.discoveredDefault.time<this.POLICY_CACHE_TTL)return this.discoveredDefault.dn;let e=null;try{const t=(await this.server.ldap.search({filter:"(objectClass=pwdPolicy)",scope:"sub",attributes:["1.1"],paged:!1},this.policyBase())).searchEntries.map(e=>e.dn);1===t.length?e=t[0]:t.length>1&&!this.warnedAmbiguousDefault&&(this.warnedAmbiguousDefault=!0,this.logger.warn(`${t.length} password policies found and no --ppolicy-default-dn: accounts without pwdPolicySubentry are reported without a policy`))}catch(e){this.logger.debug(`No ppolicy found: ${e}`)}return this.discoveredDefault={dn:e,time:Date.now()},e}async readPolicyAt(e){const t=s(e),i=this.policyCache.get(t);if(i&&Date.now()-i.time<this.POLICY_CACHE_TTL)return i.policy;let a,r=null;try{const t=await this.server.ldap.search({filter:"(objectClass=pwdPolicy)",scope:"base",attributes:y,paged:!1},e);t.searchEntries.length>0&&(r=((e,t)=>{const i=typedPolicy(e);return{dn:t,pwdMaxAge:i.pwdMaxAge,pwdMinAge:i.pwdMinAge,pwdInHistory:i.pwdInHistory,pwdCheckQuality:i.pwdCheckQuality,pwdMinLength:i.pwdMinLength,pwdMaxFailure:i.pwdMaxFailure,pwdLockout:!0===i.pwdLockout,pwdLockoutDuration:i.pwdLockoutDuration,pwdGraceAuthNLimit:i.pwdGraceAuthNLimit,pwdExpireWarning:i.pwdExpireWarning,pwdMustChange:!0===i.pwdMustChange,pwdAllowUserChange:!1!==i.pwdAllowUserChange}})(t.searchEntries[0],e))}catch(e){a=String(e)}const o=this.config.ppolicy_default_dn;return!r&&o&&s(o)===t?this.logger.warn(`--ppolicy-default-dn ${e} holds no password policy${a?`: ${a}`:""}`):a&&this.logger.debug(`Failed to read ppolicy from ${e}: ${a}`),this.policyCache.set(t,{policy:r,time:Date.now()}),r}forgetPolicies(){this.policyCache.clear(),this.discoveredDefault=null}policyBase(){return this.config.ppolicy_base||this.config.ldap_base}async searchPolicies(e,t){try{return(await this.server.ldap.search({filter:`(&(objectClass=pwdPolicy)${e})`,scope:"sub",attributes:["objectClass","cn",...y],paged:!1},this.policyBase(),t)).searchEntries}catch(e){if(32!==c(e))throw e;return this.warnedMissingBase||(this.warnedMissingBase=!0,this.logger.warn(`${this.policyBase()} does not exist: no password policy is found there, check --ppolicy-base`)),[]}}async findPolicy(e,t){const i=await this.searchPolicies(`(cn=${a(e)})`,t);if(0===i.length)throw new l(`No password policy named ${e}`);if(i.length>1)throw new d(`Several password policies are named ${e}`);return i[0]}async listPolicies(e){const t=[];for(const i of await this.searchPolicies("",e))t.push(await this.toPolicyRecord(i));return t.sort((e,t)=>e.cn.localeCompare(t.cn))}async definedAttributes(){const e=Object.keys(h);try{const t=await this.server.ldap.schemaIndex();return e.filter(e=>t.getAttributeType(e))}catch(t){return this.warnedUnreadableSchema||(this.warnedUnreadableSchema=!0,this.logger.warn(`Unable to read the schema, every policy attribute is listed: ${String(t)}`)),e}}async toPolicyRecord(e){const t=(await this.readPolicyConfig()).dn;return{...{dn:e.dn,cn:values(e.cn)[0]??"",isDefault:!!t&&s(t)===s(e.dn)},...typedPolicy(e)}}policyValue(e,t){switch(h[e]){case"integer":if("number"!=typeof t||!Number.isInteger(t)||t<0||"pwdCheckQuality"===e&&t>2)throw new p("pwdCheckQuality"===e?"pwdCheckQuality must be 0, 1 or 2":`${e} must be a non-negative integer`);return String(t);case"boolean":if("boolean"!=typeof t)throw new p(`${e} must be a boolean`);return t?"TRUE":"FALSE";default:if("string"!=typeof t||""===t)throw new p(`${e} must be a non-empty string`);return t}}async createPolicy(e,t){const{cn:i,...s}=e;if("string"!=typeof i)throw new p("cn required");try{r(i,"cn")}catch(e){throw new p(e.message)}const a=await this.findPolicy(i,t).catch(e=>{if(e instanceof l)return null;throw e});if(a)throw new d(`A password policy is named ${i}: ${a.dn}`);const n=["top","person","pwdPolicy"],u={objectClass:n,cn:i,sn:i,pwdAttribute:"userPassword"};for(const[e,t]of Object.entries(s)){if(!isWritablePolicyAttribute(e))throw new p(`${e} cannot be written`);null!==t&&(u[e]=this.policyValue(e,t))}w.some(e=>e in u)&&n.push("pwdPolicyChecker");try{await this.server.ldap.add(`cn=${o(i)},${this.policyBase()}`,u,t)}catch(e){if(32!==c(e))throw e;throw new l(`${this.policyBase()} does not exist: create it or change --ppolicy-base`)}return this.forgetPolicies(),this.toPolicyRecord(await this.findPolicy(i,t))}async updatePolicy(t,i,a){const r=await this.findPolicy(t,a),o=await this.toPolicyRecord(r),n={},c=[];for(const[t,a]of Object.entries(i)){if(!isWritablePolicyAttribute(t)){if("dn"===t?"string"==typeof a&&s(a)===s(o.dn):Object.prototype.hasOwnProperty.call(o,t)&&e(o[t],a))continue;throw new p(`${t} cannot be written`)}null!==a?n[t]=this.policyValue(t,a):void 0!==o[t]&&c.push(t)}const l={};if(Object.keys(n).length>0&&(l.replace=n),c.length>0&&(l.delete=c),!l.replace&&!l.delete)return o;const d=values(r.objectClass).map(e=>e.toLowerCase());return w.some(e=>e in n)&&!d.includes("pwdpolicychecker")&&(l.add={objectClass:"pwdPolicyChecker"}),await this.server.ldap.modify(r.dn,l,a),this.forgetPolicies(),this.toPolicyRecord(await this.findPolicy(t,a))}async deletePolicy(e,t){const i=await this.findPolicy(e,t),r=(await this.readPolicyConfig()).dn;if(r&&s(r)===s(i.dn))throw new d(`${i.dn} is the default password policy`);if((await this.server.ldap.search({filter:`(pwdPolicySubentry=${a(i.dn)})`,scope:"sub",attributes:["1.1"],sizeLimit:1,paged:!1},this.config.ldap_base)).searchEntries.length>0)throw new d(`${i.dn} is still applied to at least one entry`);await this.server.ldap.delete(i.dn,t),this.forgetPolicies()}async assignPolicy(e,t,i){const s=this.resolveUserDn(e);if(null===t){try{await this.server.ldap.modify(s,{delete:["pwdPolicySubentry"]},i)}catch(e){if(16!==c(e))throw e}return{dn:s,policy:null}}const a=await this.findPolicy(t,i);return await this.server.ldap.modify(s,{replace:{pwdPolicySubentry:a.dn}},i),{dn:s,policy:a.dn}}param(e,t){const i=e.params[t];return Array.isArray(i)?i[0]:i}parseGeneralizedTime(e){if(!e)return null;const t=e.match(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/);if(!t)return null;const[,i,s,a,r,o,n]=t;return new Date(Date.UTC(+i,+s-1,+a,+r,+o,+n))}validateComplexity(e){const t=[],i=this.config,s=i.ppolicy_min_length||12;return e.length<s&&t.push(`Minimum ${s} characters required`),i.ppolicy_require_uppercase&&!/[A-Z]/.test(e)&&t.push("At least one uppercase letter required"),i.ppolicy_require_lowercase&&!/[a-z]/.test(e)&&t.push("At least one lowercase letter required"),i.ppolicy_require_digit&&!/\d/.test(e)&&t.push("At least one digit required"),i.ppolicy_require_special&&!g.test(e)&&t.push("At least one special character required"),{valid:0===t.length,errors:t}}resolveUserDn(e){if(e.includes("="))return e;const t=this.config.ldap_users_base||this.config.ldap_base;return`${this.config.ldap_user_main_attribute||"uid"}=${o(e)},${t}`}getConfigApiData(){const e=this.config.api_prefix||"/api",t=!!this.config.ppolicy_validate_complexity;return{name:this.name,enabled:!0,endpoints:{getPolicy:`${e}/v1/password-policy`,getUserStatus:`${e}/v1/users/:id/password-status`,unlockUser:`${e}/v1/users/:id/unlock`,getExpiringSoon:`${e}/v1/password-policy/expiring-soon`,getLockedAccounts:`${e}/v1/password-policy/locked-accounts`,listPolicies:`${e}/v1/password-policies`,policy:`${e}/v1/password-policies/:cn`,assignPolicy:`${e}/v1/users/:id/password-policy`,validatePassword:t?`${e}/v1/password/validate`:void 0},config:{warnDays:this.config.ppolicy_warn_days||14,validateComplexity:t,...t&&{complexityRules:{minLength:this.config.ppolicy_min_length||12,requireUppercase:this.config.ppolicy_require_uppercase??!0,requireLowercase:this.config.ppolicy_require_lowercase??!0,requireDigit:this.config.ppolicy_require_digit??!0,requireSpecial:this.config.ppolicy_require_special??!0}}}}}}export{PasswordPolicy as default};
@@ -125,6 +125,12 @@ export interface Config {
125
125
  * organization is in transit too, with or without this option.
126
126
  */
127
127
  authz_transit_branch?: string;
128
+ /**
129
+ * The attribute an entry put in transit keeps the organization it leaves
130
+ * in, removed when it is claimed: who claims it can tell where it comes
131
+ * from. Empty records nothing. It must exist in the directory's schema.
132
+ */
133
+ authz_transit_origin_attribute?: string;
128
134
  authz_unresolved_user?: string;
129
135
  authz_identity?: string;
130
136
  /**
@@ -63,6 +63,24 @@ export default abstract class AuthzBase extends DmPlugin {
63
63
  * only a caller holding the branch reads. None unless a plugin names some.
64
64
  */
65
65
  protected globalBranches(): string[];
66
+ /**
67
+ * Why `user` may not search under `base`, or `undefined` when they may:
68
+ * the judgement of `ldapsearchrequest`, for `authzScope` to ask it of a
69
+ * branch without searching it.
70
+ *
71
+ * Filtering, an administrator reads the directory whole — the
72
+ * organization tree, the groups, the nomenclatures — and what is held to
73
+ * a branch is the accounts, recognised one entry at a time by
74
+ * `ldapsearchfilter`; only a global branch asks for the branch itself.
75
+ * Otherwise the branch decides. Where a branch is a tenant rather than a
76
+ * department, letting a listing cross it is letting a customer read
77
+ * another.
78
+ *
79
+ * @param user the caller's DN
80
+ * @param base the base of the search
81
+ * @returns the end of the refusal's message, after the caller's name
82
+ */
83
+ searchRefusal(user: string, base: string): Promise<string | undefined>;
66
84
  /**
67
85
  * What taking an entry out of a branch, by a move or a rename, asks of
68
86
  * the caller there: read, by default, the destination being judged on
@@ -100,6 +118,27 @@ export default abstract class AuthzBase extends DmPlugin {
100
118
  * @param key the transit branch as {@link transitKey} gives it
101
119
  */
102
120
  protected inTransit(link: string | undefined, key?: string | undefined): boolean;
121
+ /**
122
+ * The attribute an entry in transit keeps the organization it was handed
123
+ * over from in: `--authz-transit-origin-attribute`, with a transit branch
124
+ * to hand it over through. `undefined` records nothing.
125
+ */
126
+ protected transitOriginAttribute(): string | undefined;
127
+ /**
128
+ * A modify that changes an entry's organization link, with the origin of
129
+ * a hand-over recorded: put in transit, the entry keeps the organization
130
+ * it leaves in {@link transitOriginAttribute}; claimed out of it, it loses
131
+ * it. The administrator who claims it can then tell where it comes from.
132
+ *
133
+ * The changes are left alone when they already name the attribute, and
134
+ * when the stored entry cannot be read: a failed read must not cost the
135
+ * move itself, which is judged elsewhere.
136
+ *
137
+ * @param dn the entry modified
138
+ * @param changes the modify as it stands
139
+ * @returns the modify, with the origin set or removed
140
+ */
141
+ protected recordTransitOrigin(dn: string, changes: ModifyRequest): Promise<ModifyRequest>;
103
142
  /**
104
143
  * The transit branch in the form DNs are compared in. A listing asks once
105
144
  * and hands it to {@link inTransit} for every entry, rather than parsing
@@ -7,7 +7,27 @@
7
7
  */
8
8
  export declare class HttpError extends Error {
9
9
  statusCode: number;
10
- constructor(message: string, statusCode?: number);
10
+ refusal?: Refusal | undefined;
11
+ constructor(message: string, statusCode?: number, refusal?: Refusal | undefined);
12
+ }
13
+ /**
14
+ * What a client is told of a refusal besides its sentence: enough to say it
15
+ * in its own language and next to the field at fault, without reading the
16
+ * sentence back. Added to the `error` field of the answer, never in place
17
+ * of it.
18
+ */
19
+ export interface Refusal {
20
+ /**
21
+ * `unique` (a value already used), `mailDomain` (an address outside the
22
+ * authorised domains) or `forbidden` (an authorization plugin refused)
23
+ */
24
+ code: 'unique' | 'mailDomain' | 'forbidden';
25
+ /** The attribute refused */
26
+ attribute?: string;
27
+ /** The value refused */
28
+ value?: string;
29
+ /** The authorised mail domains, for `mailDomain` */
30
+ domains?: string[];
11
31
  }
12
32
  /**
13
33
  * Client errors (4xx)
@@ -23,6 +43,19 @@ export declare class UnauthorizedError extends HttpError {
23
43
  * threw names the branch, which the caller may not know of.
24
44
  */
25
45
  export declare const AUTHZ_REFUSED = "Token does not have permission on this branch";
46
+ /**
47
+ * The JSON answer of a client error: its sentence, and the refusal it
48
+ * carries. An authorization plugin's refusal carries none — its message names
49
+ * the branch, which is why the client is told `AUTHZ_REFUSED` — so the code
50
+ * is given here, from the sentence the caller settled on.
51
+ *
52
+ * @param err the error thrown
53
+ * @param message the sentence the client is told
54
+ * @returns the body to answer
55
+ */
56
+ export declare const errorBody: (err: unknown, message: string) => {
57
+ error: string;
58
+ } & Partial<Refusal>;
26
59
  export declare class ForbiddenError extends HttpError {
27
60
  constructor(message?: string);
28
61
  }
@@ -45,7 +78,7 @@ export declare class NotFoundError extends HttpError {
45
78
  constructor(message?: string);
46
79
  }
47
80
  export declare class ConflictError extends HttpError {
48
- constructor(message?: string);
81
+ constructor(message?: string, refusal?: Refusal);
49
82
  }
50
83
  export declare class UriTooLongError extends HttpError {
51
84
  constructor(message?: string);
@@ -83,6 +83,25 @@ export default class AuthzScope extends DmPlugin {
83
83
  * @param res Express response
84
84
  */
85
85
  private scope;
86
+ /** The password policy plugin, when loaded. */
87
+ private policyPlugin;
88
+ /**
89
+ * The `passwordPolicies` field: nothing when no plugin serves the policies,
90
+ * so that "not served" is not read as "not yours".
91
+ *
92
+ * @param may whether the caller may list them
93
+ */
94
+ private passwordPolicies;
95
+ /**
96
+ * Whether the describing plugin lets the caller list the password
97
+ * policies: the judgement its search hook makes of the listing, asked
98
+ * without the listing. A plugin that cannot say does not offer them, as
99
+ * `linksAnyDomain` does not.
100
+ *
101
+ * @param authz the describing plugin
102
+ * @param user the caller's DN
103
+ */
104
+ private readsPolicies;
86
105
  /**
87
106
  * Read the display name and path of a branch, so a client can show the
88
107
  * scope in the words the directory uses rather than as a raw DN.
@@ -54,6 +54,16 @@ import type { DM } from '../../bin';
54
54
  * error:
55
55
  * type: string
56
56
  * description: Error message.
57
+ * code:
58
+ * type: string
59
+ * description: |
60
+ * `unique` or `mailDomain`, with `attribute`, `value` and
61
+ * `domains`, as in the `Error` answer of a single write.
62
+ * attribute: { type: string }
63
+ * value: { type: string }
64
+ * domains:
65
+ * type: array
66
+ * items: { type: string }
57
67
  * example:
58
68
  * - line: 5
59
69
  * identifier: bob
@@ -226,7 +226,8 @@ export default class LdapEnterpriseRules extends DmPlugin {
226
226
  * Refuse a mail address outside the domains its organization may use.
227
227
  *
228
228
  * @param entity entity the entry belongs to
229
- * @param attr schema definition of the mail attribute
229
+ * @param name the mail attribute
230
+ * @param attr its schema definition
230
231
  * @param values addresses submitted
231
232
  * @param submitted attributes as submitted, read for the organization link
232
233
  * @param current entry as stored, when the link is not being changed
@@ -80,12 +80,35 @@ export type postModify = ModifyRequest;
80
80
  * description: Updated description
81
81
  * Error:
82
82
  * type: object
83
+ * required: [error]
83
84
  * properties:
84
- * error: { type: string }
85
- * code: { type: integer }
85
+ * error:
86
+ * type: string
87
+ * description: What was refused, in English.
88
+ * code:
89
+ * type: string
90
+ * description: |
91
+ * For a client to explain the refusal in its own words, when there
92
+ * is one: `unique` (a value already used), `mailDomain` (an address
93
+ * outside the authorised mail domains), `forbidden` (the caller may
94
+ * not act on this branch). Absent for any other refusal, which only
95
+ * `error` describes. The Twake groups API answers its own codes
96
+ * (`INVALID_INPUT`, `REFUSED`…).
97
+ * attribute:
98
+ * type: string
99
+ * description: The attribute refused, for `unique` and `mailDomain`.
100
+ * value:
101
+ * type: string
102
+ * description: The value refused, for `unique` and `mailDomain`.
103
+ * domains:
104
+ * type: array
105
+ * items: { type: string }
106
+ * description: The authorised mail domains, for `mailDomain`.
86
107
  * example:
87
- * error: Group not found
88
- * code: 404
108
+ * error: 'entity: "value" is already used (attribute must be unique)'
109
+ * code: unique
110
+ * attribute: attribute
111
+ * value: value
89
112
  */
90
113
  export default class LdapGroups extends DmPlugin {
91
114
  name: string;
@@ -171,6 +194,18 @@ export default class LdapGroups extends DmPlugin {
171
194
  * @param req the request, to authorize the change against its caller
172
195
  */
173
196
  deleteMember(cn: string, member: string, req?: Request): Promise<boolean>;
197
+ /**
198
+ * Take a member out of a group. The last member of a `groupOfNames` cannot
199
+ * simply go — the schema wants one (objectClassViolation): the placeholder
200
+ * takes its place in the same change, as it does in a group created empty.
201
+ * With no placeholder configured, the group cannot be left empty, which is
202
+ * the caller's to resolve, not a fault of the server.
203
+ *
204
+ * @param dn group DN
205
+ * @param member member DN
206
+ * @param req the request, to authorize the change against its caller
207
+ */
208
+ private removeMember;
174
209
  deleteMemberFromAll(memberDn: string): Promise<void>;
175
210
  /**
176
211
  * Move a group to a different organization by updating organization link and path attributes
@@ -239,6 +239,7 @@ export default class PasswordPolicy extends DmPlugin {
239
239
  private discoveredDefault;
240
240
  private warnedAmbiguousDefault;
241
241
  private warnedMissingBase;
242
+ private warnedUnreadableSchema;
242
243
  private readonly POLICY_CACHE_TTL;
243
244
  api(app: Express): void;
244
245
  /**
@@ -288,7 +289,11 @@ export default class PasswordPolicy extends DmPlugin {
288
289
  */
289
290
  private readPolicyAt;
290
291
  private forgetPolicies;
291
- private policyBase;
292
+ /**
293
+ * Where the policies are listed from: `--ppolicy-base`, else
294
+ * `--ldap-base`. `authzScope` asks whether a caller may search it.
295
+ */
296
+ policyBase(): string;
292
297
  /**
293
298
  * The policy entries under the policy base matching `filter`, none when
294
299
  * the base itself is missing
@@ -299,6 +304,13 @@ export default class PasswordPolicy extends DmPlugin {
299
304
  */
300
305
  private findPolicy;
301
306
  private listPolicies;
307
+ /**
308
+ * The writable policy attributes the directory's schema defines: OpenLDAP
309
+ * 2.4 lacks pwdMaxLength, pwdMaxIdle, pwdMinDelay and pwdMaxDelay, and
310
+ * only 2.6 has the checker's. All of them when the schema cannot be read:
311
+ * the directory still refuses what it lacks.
312
+ */
313
+ private definedAttributes;
302
314
  private toPolicyRecord;
303
315
  /**
304
316
  * The directory form of a value a client sent for policy attribute `name`
@@ -1142,6 +1142,20 @@ All errors return a JSON object with an error message:
1142
1142
  }
1143
1143
  ```
1144
1144
 
1145
+ The message is English, for a log or a developer. The refusals a client is
1146
+ expected to explain to its users carry a `code` too, and what was refused,
1147
+ for the client to say it in its own words and next to the field at fault.
1148
+ `error` is always there; read the other fields only when `code` is:
1149
+
1150
+ | `code` | Status | Refusal | Other fields |
1151
+ | ------------ | ------ | ------------------------------------------------------------ | ------------------------------- |
1152
+ | `unique` | 409 | A value another entry already holds | `attribute`, `value` |
1153
+ | `mailDomain` | 409 | An address outside the mail domains its organization may use | `attribute`, `value`, `domains` |
1154
+ | `forbidden` | 403 | The caller may not act on this branch | none: the branch is not named |
1155
+
1156
+ Any other refusal has no `code`, and only its message says what it is. The
1157
+ bulk import reports the same fields on each refused row.
1158
+
1145
1159
  ### Common Errors
1146
1160
 
1147
1161
  #### 400 Bad Request
@@ -1210,6 +1224,18 @@ not a DN (LDAP result code 34):
1210
1224
  }
1211
1225
  ```
1212
1226
 
1227
+ #### 403 Forbidden
1228
+
1229
+ **Refused by authorization** — the caller may not read, write or delete in
1230
+ this branch. The branch is not named, the caller may not know of it:
1231
+
1232
+ ```json
1233
+ {
1234
+ "error": "Token does not have permission on this branch",
1235
+ "code": "forbidden"
1236
+ }
1237
+ ```
1238
+
1213
1239
  #### 404 Not Found
1214
1240
 
1215
1241
  **User Not Found:**
@@ -1259,6 +1285,32 @@ result codes 32 and 16). Removing a group member that is not one names both:
1259
1285
  A rename or a move onto a DN already taken answers the same way, naming that
1260
1286
  DN (LDAP result code 68).
1261
1287
 
1288
+ **Value already used** — a value the schema says is unique
1289
+ ([`unique`](../../usage/plugins/ldap/enterprise-rules.md)), held by another
1290
+ entry, which is not named:
1291
+
1292
+ ```json
1293
+ {
1294
+ "error": "user: \"alice@example.com\" is already used (mail must be unique)",
1295
+ "code": "unique",
1296
+ "attribute": "mail",
1297
+ "value": "alice@example.com"
1298
+ }
1299
+ ```
1300
+
1301
+ **Mail domain not authorised** — an address outside the domains the entry's
1302
+ organization, or one of its ancestors, links to:
1303
+
1304
+ ```json
1305
+ {
1306
+ "error": "The mail domain of \"x.user@other.org\" is not one of the authorised domains (example.com)",
1307
+ "code": "mailDomain",
1308
+ "attribute": "mail",
1309
+ "value": "x.user@other.org",
1310
+ "domains": ["example.com"]
1311
+ }
1312
+ ```
1313
+
1262
1314
  **Entry that cannot hold a change** — a modify adding an attribute the
1263
1315
  classes of the entry do not allow, or a value it already holds (LDAP result
1264
1316
  codes 65 and 20 on a modify):
@@ -417,12 +417,13 @@ whichever of the two names it selects. A value other than `req.user` or
417
417
 
418
418
  #### `core/auth/authzPerBranch`
419
419
 
420
- | CLI | Env | Default | Description |
421
- | --------------------------------- | ---------------------------------- | ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
422
- | `--authz-per-branch-config` | `DM_AUTHZ_PER_BRANCH_CONFIG` | `{default:{read:true,write:false,delete:false}}` | Authorization config (JSON) |
423
- | `--authz-per-branch-cache-ttl` | `DM_AUTHZ_PER_BRANCH_CACHE_TTL` | `60` | Cache TTL (seconds) |
424
- | `--authz-filter-attached-entries` | `DM_AUTHZ_FILTER_ATTACHED_ENTRIES` | `false` | Judge an attached entry by the organization it hangs off, and filter listings accordingly. Honoured by `authzPerBranch`; always on under `authzLinid1`; does nothing under `authzDynamic`. |
425
- | `--authz-transit-branch` | `DM_AUTHZ_TRANSIT_BRANCH` | | Organization entries are handed over through when they are judged by attachment: every administrator sees and may claim what is there, and puts an entry there with write on the organization it leaves. An entry attached to no organization is in transit too. See [authzLinid1](plugins/auth/authz-linid1.md#transit-handing-an-entry-over). |
420
+ | CLI | Env | Default | Description |
421
+ | ---------------------------------- | ----------------------------------- | ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
422
+ | `--authz-per-branch-config` | `DM_AUTHZ_PER_BRANCH_CONFIG` | `{default:{read:true,write:false,delete:false}}` | Authorization config (JSON) |
423
+ | `--authz-per-branch-cache-ttl` | `DM_AUTHZ_PER_BRANCH_CACHE_TTL` | `60` | Cache TTL (seconds) |
424
+ | `--authz-filter-attached-entries` | `DM_AUTHZ_FILTER_ATTACHED_ENTRIES` | `false` | Judge an attached entry by the organization it hangs off, and filter listings accordingly. Honoured by `authzPerBranch`; always on under `authzLinid1`; does nothing under `authzDynamic`. |
425
+ | `--authz-transit-branch` | `DM_AUTHZ_TRANSIT_BRANCH` | | Organization entries are handed over through when they are judged by attachment: every administrator sees and may claim what is there, and puts an entry there with write on the organization it leaves. An entry attached to no organization is in transit too. See [authzLinid1](plugins/auth/authz-linid1.md#transit-handing-an-entry-over). |
426
+ | `--authz-transit-origin-attribute` | `DM_AUTHZ_TRANSIT_ORIGIN_ATTRIBUTE` | | Attribute an entry put in transit keeps the organization it leaves in, removed when it is claimed. Empty records nothing; it must exist in the directory's schema. See [authzLinid1](plugins/auth/authz-linid1.md#where-an-entry-comes-from). |
426
427
 
427
428
  #### `core/auth/authzScope`
428
429
 
@@ -96,6 +96,40 @@ to the transit branch follows the same rules through
96
96
  `POST /api/v1/ldap/groups/:cn/move`; that endpoint does not move a group
97
97
  attached to no organization.
98
98
 
99
+ #### Where an entry comes from
100
+
101
+ Once in transit, an entry no longer says which organization it left, and the
102
+ administrator who would claim it cannot tell whether it is theirs. Name an
103
+ attribute to keep it in; `twakeTransitOrigin` below is an example, which
104
+ the directory's schema must define (see the rules after it):
105
+
106
+ ```bash
107
+ --authz-transit-origin-attribute twakeTransitOrigin
108
+ ```
109
+
110
+ Putting an entry in transit, by `move` or by any modify of its
111
+ `twakeDepartmentLink`, then sets that attribute to the DN of the organization
112
+ it leaves; claiming it removes the attribute. A move from transit to transit
113
+ keeps it, and an entry that was attached to no organization gets none. A
114
+ modify that sets or removes the attribute itself is left as it is. The name
115
+ is compared as LDAP does, whatever its case. Without a transit branch, the
116
+ option does nothing.
117
+
118
+ The attribute is the directory's to allow, and its rules apply to the whole
119
+ move: what it refuses, it refuses the move with.
120
+
121
+ - **A dedicated attribute.** The origin is written with a `replace`: every
122
+ value the attribute held is overwritten. A business attribute such as
123
+ `departmentNumber`, which may hold several real values, would lose them.
124
+ The Twake schema has no attribute for this: add one to the directory's
125
+ schema.
126
+ - **Optional.** Claiming the entry removes the attribute, so it must be
127
+ among the `MAY` of its object classes, never a `MUST`. It must be allowed
128
+ on every entry that can be handed over, accounts and groups alike.
129
+ - **Writable.** The directory's ACLs must let ldap-rest's bind write it.
130
+
131
+ Without the option, nothing is recorded.
132
+
99
133
  ### Mail domains
100
134
 
101
135
  The mail domains an organization's entries may use are those it links in
@@ -145,7 +179,8 @@ a search started above the branch leaves its entries out. That is how only
145
179
  the administrators of the whole directory manage the
146
180
  [password policies](../ldap/password-policy.md#managing-policies). A global
147
181
  branch is no branch of theirs in `GET /authz/scope`, so it adds nothing to
148
- the organizations they are offered.
182
+ the organizations they are offered; `passwordPolicies` there says whether
183
+ they may list the policies.
149
184
 
150
185
  `--authz-global-write-branches` names branches whose writes and deletes are
151
186
  held the same way, while reading them stays as for the rest of the reference
@@ -495,11 +495,26 @@ newuser,New User,User,new2@example.com
495
495
  "line": 10,
496
496
  "identifier": "user009",
497
497
  "error": "Invalid DN format"
498
+ },
499
+ {
500
+ "line": 11,
501
+ "identifier": "user010",
502
+ "error": "user: \"jane@example.com\" is already used (mail must be unique)",
503
+ "code": "unique",
504
+ "attribute": "mail",
505
+ "value": "jane@example.com"
498
506
  }
499
507
  ]
500
508
  }
501
509
  ```
502
510
 
511
+ A row refused by a rule a client is expected to explain — a value already
512
+ used, a mail domain outside the authorised ones, with
513
+ [enterprise rules](enterprise-rules.md) loaded — carries the same `code`,
514
+ `attribute`, `value` and `domains` as the answer of a single write: see
515
+ [the error format](../../../client-development/api/rest-api.md#error-response-format).
516
+ Other rows only have `error`.
517
+
503
518
  ## Best Practices
504
519
 
505
520
  ### Handling Partial Failures
@@ -183,6 +183,13 @@ device. Members leave their groups through the groups plugin.
183
183
  | `400` | A size that is not a size, a date already past |
184
184
  | `409` | A duplicate value, a mail domain outside the authorised set, a guarded delete |
185
185
 
186
+ A duplicate value answers `code: "unique"` with the `attribute` and the
187
+ `value` refused, and a mail domain outside the authorised set answers
188
+ `code: "mailDomain"` with them and the authorised `domains`, each listed once:
189
+ a client explains them in its own words, under the field at fault. The
190
+ holder of a duplicate value is never named.
191
+ See [the error format](../../../client-development/api/rest-api.md#error-response-format).
192
+
186
193
  ## See also
187
194
 
188
195
  - [flat-generic](flat-generic.md) — the schema format these markers extend
@@ -363,7 +363,12 @@ curl -X DELETE "http://localhost:8081/api/v1/ldap/groups/developers/members/uid%
363
363
 
364
364
  **Notes:**
365
365
 
366
- - If removing the last real member, the dummy member is automatically added
366
+ - If removing the last real member, the dummy member (`--group-dummy-user`)
367
+ takes its place in the same change, so the group stays valid and reads back
368
+ with no member; with no dummy member configured, the removal is refused
369
+ with a 409, as a `groupOfNames` needs at least one member
370
+ - Removing the dummy member itself is refused with a 400, and a DN that is not
371
+ a member of the group with a 404
367
372
  - Member DN must be URL-encoded in the path
368
373
 
369
374
  **Response (200):**
@@ -342,7 +342,12 @@ curl -X PATCH "http://localhost:8081/api/v1/password-policies/default" \
342
342
  `pwdUseCheckModule`, gives the entry the `pwdPolicyChecker` class. Both
343
343
  need OpenLDAP 2.6; an older directory refuses them with a `400`.
344
344
  - An attribute the directory's schema does not know is refused with a `400`
345
- naming it.
345
+ naming it. The list answers, next to `policies`, the `attributes` a policy
346
+ can be given: those of the above that the directory's schema defines.
347
+ OpenLDAP 2.4 lacks `pwdMaxLength`, `pwdMaxIdle`, `pwdMinDelay` and
348
+ `pwdMaxDelay`. The schema is read once per `--ldap-raw-schema-cache-ttl`:
349
+ after an upgrade of the directory, the list follows within that delay, or
350
+ at the next restart.
346
351
 
347
352
  **Creating a policy:** `POST` takes `cn` and the attributes to set. The entry
348
353
  is created under `--ppolicy-base` as a `person` holding `pwdPolicy`, as in
@@ -357,6 +362,13 @@ every administrator reads there: name `--ppolicy-base` in
357
362
  directory read and change the policies
358
363
  ([global branches](../auth/authz-linid1.md#global-branches)).
359
364
 
365
+ `GET /authz/scope` answers `passwordPolicies: true` to a caller who may list
366
+ the policies, and `false` to the others, so that a client offers them
367
+ without asking for them and being refused. It is the judgement the
368
+ authorization plugin describing the scope makes of a search of
369
+ `--ppolicy-base`, which is the search the listing makes; the field is
370
+ absent when this plugin is not loaded.
371
+
360
372
  **Deleting a policy** is refused (`409`) while it is the default one or an
361
373
  entry's `pwdPolicySubentry` names it. The default one is only known through
362
374
  `--ppolicy-default-dn` when there are several: without it, nothing stops
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ldap-rest",
3
- "version": "0.24.0",
3
+ "version": "0.25.1",
4
4
  "description": "Light directory manager",
5
5
  "keywords": [
6
6
  "Identity-Manager"