ldap-rest 0.3.9 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -38,6 +38,11 @@ Environment variables:
38
38
  process.exit(0);
39
39
  }
40
40
 
41
+ // A base-scoped LDAP search on a non-existent entry returns noSuchObject (32)
42
+ // rather than an empty result set, so callers must treat it as "absent".
43
+ const isNoSuchObject = err =>
44
+ err?.code === 32 || /no such object|0x20/i.test(err?.message || '');
45
+
41
46
  async function syncAppAccounts() {
42
47
  const dm = new DM();
43
48
  await dm.ready;
@@ -56,6 +61,20 @@ async function syncAppAccounts() {
56
61
  process.exit(1);
57
62
  }
58
63
 
64
+ // Fail fast with a clear message if the applicative branch is missing,
65
+ // instead of letting the orphan scan abort with a raw NoSuchObjectError.
66
+ try {
67
+ await dm.ldap.search({ scope: 'base', paged: false }, applicativeBase);
68
+ } catch (err) {
69
+ if (isNoSuchObject(err)) {
70
+ dm.logger.error(
71
+ `Applicative base ${applicativeBase} does not exist. Create it first (e.g. an organizationalUnit entry) before running this sync.`
72
+ );
73
+ process.exit(1);
74
+ }
75
+ throw err;
76
+ }
77
+
59
78
  if (!quiet) {
60
79
  dm.logger.info('Starting applicative accounts synchronization...');
61
80
  dm.logger.info(`User base: ${userBase}`);
@@ -101,15 +120,26 @@ async function syncAppAccounts() {
101
120
  if (!mail) continue;
102
121
 
103
122
  try {
104
- // Check if principal account exists
123
+ // Check if principal account exists. A base search on a missing entry
124
+ // throws noSuchObject rather than returning an empty set, so we treat
125
+ // that exception as "absent" and fall through to creation.
105
126
  const principalDn = `uid=${mail},${applicativeBase}`;
106
- const principalResult = await dm.ldap.search(
107
- {
108
- scope: 'base',
109
- paged: false,
110
- },
111
- principalDn
112
- );
127
+ let principalResult;
128
+ try {
129
+ principalResult = await dm.ldap.search(
130
+ {
131
+ scope: 'base',
132
+ paged: false,
133
+ },
134
+ principalDn
135
+ );
136
+ } catch (err) {
137
+ if (isNoSuchObject(err)) {
138
+ principalResult = { searchEntries: [] };
139
+ } else {
140
+ throw err;
141
+ }
142
+ }
113
143
 
114
144
  if (
115
145
  !principalResult.searchEntries ||
@@ -136,17 +166,23 @@ async function syncAppAccounts() {
136
166
  'displayName',
137
167
  ];
138
168
  for (const attr of attrsToCopy) {
139
- if (user[attr]) {
140
- const value = user[attr];
141
- if (Array.isArray(value)) {
142
- attrs[attr] = value.map(v =>
143
- Buffer.isBuffer(v) ? v.toString() : String(v)
144
- );
145
- } else {
146
- attrs[attr] = Buffer.isBuffer(value)
147
- ? value.toString()
148
- : String(value);
149
- }
169
+ const value = user[attr];
170
+ // Skip absent values. A requested-but-missing attribute comes
171
+ // back as an empty array (truthy in JS), which would otherwise
172
+ // produce an `add` with no values → ProtocolError "no values for
173
+ // attribute type".
174
+ if (value === undefined || value === null || value === '') {
175
+ continue;
176
+ }
177
+ if (Array.isArray(value)) {
178
+ if (value.length === 0) continue;
179
+ attrs[attr] = value.map(v =>
180
+ Buffer.isBuffer(v) ? v.toString() : String(v)
181
+ );
182
+ } else {
183
+ attrs[attr] = Buffer.isBuffer(value)
184
+ ? value.toString()
185
+ : String(value);
150
186
  }
151
187
  }
152
188
 
@@ -265,12 +301,19 @@ async function syncAppAccounts() {
265
301
  dm.logger.error('Error during synchronization:', err);
266
302
  process.exit(1);
267
303
  } finally {
268
- await dm.ldap.unbind();
304
+ // ldapActions exposes no public unbind; pooled connections are released on
305
+ // process exit. Guard so this teardown never throws if the method is absent.
306
+ if (typeof dm.ldap.unbind === 'function') {
307
+ await dm.ldap.unbind();
308
+ }
269
309
  }
270
310
  }
271
311
 
272
- // Run the sync
273
- syncAppAccounts().catch(err => {
274
- console.error('Fatal error:', err);
275
- process.exit(1);
276
- });
312
+ // Run the sync. Pooled LDAP connections keep the event loop alive, so exit
313
+ // explicitly once the sync resolves instead of hanging.
314
+ syncAppAccounts()
315
+ .then(() => process.exit(0))
316
+ .catch(err => {
317
+ console.error('Fatal error:', err);
318
+ process.exit(1);
319
+ });
package/dist/bin/index.js CHANGED
@@ -1 +1 @@
1
- import{fileURLToPath as e}from"url";import{join as r,dirname as i}from"path";import s from"express";import o from"body-parser";import{parseConfig as t}from"../lib/parseConfig.js";import n from"../config/args.js";import a from"../lib/ldapActions.js";import{buildLogger as l}from"../logger/winston.js";import{setLogger as d}from"../lib/expressFormatedResponses.js";import g from"../plugins/priority.json.js";export{asyncHandler,escapeDnValue,escapeLdapFilter,escapeRegex,getCompiledRegex,getParentDn,getRdn,isChildOf,launchHooks,launchHooksChained,parseDn,transformSchemas,unescapeDnValue,validateDnValue}from"../lib/utils.js";export{BadGatewayError,BadRequestError,ConflictError,ForbiddenError,GatewayTimeoutError,HttpError,NotFoundError,ServiceUnavailableError,TooManyRequestsError,UnauthorizedError,UriTooLongError}from"../lib/errors.js";class DM{constructor(){this.hooks={},this.loadedPlugins={},this._errorMiddlewareSetup=!1,this.config=t(n),this.app=s(),this.app.use(o.json()),this.app.use(o.urlencoded({extended:!0})),this.logger=l(this.config),this.ldap=new a(this),d(this.logger),this.operationSequence=0;const e=[];if(this.config.plugin){const r=this.config.plugin.find(e=>e.includes("configApi"));let i=this.config.plugin.filter(e=>!e.includes("configApi"));const s=(async()=>{for(const e of g)i.includes(e)&&(i=i.filter(r=>r!==e),await this.loadPlugin(e))})();e.push(s),e.push(s.then(async()=>{const e=i.map(e=>this.loadPlugin(e));await Promise.all(e)})),r&&e.push(Promise.all(e).then(()=>this.loadPlugin(r)))}this.ready=new Promise((r,i)=>{e.length>0?Promise.all(e).then(()=>{this.setupErrorMiddleware(),r()}).catch(e=>i(new Error("Error loading plugins: "+e))):(this.setupErrorMiddleware(),r())})}setupErrorMiddleware(){if(this._errorMiddlewareSetup&&this._errorMiddleware){const e=this.app._router?.stack;if(e){const r=e.findIndex(e=>e.handle===this._errorMiddleware);-1!==r&&e.splice(r,1)}}this._errorMiddleware=(e,r,i,s)=>{let o="statusCode"in e?e.statusCode:500,t=e.message;if(500===o&&/\[authz-forbidden\]/.test(e.message)?(o=403,t="Token does not have permission on this branch"):403===o&&/\[authz-forbidden\]/.test(e.message)&&(t="Token does not have permission on this branch"),o>=400&&o<500)return this.logger.warn(`Client error ${o} in request ${r.method} ${r.path}: ${t}`),i.headersSent?void 0:i.status(o).json({error:t});this.logger.error(`Server error ${o} in request ${r.method} ${r.path}: ${e.message}`,{stack:e.stack,url:r.url,method:r.method}),i.headersSent||i.status(o).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,r)=>{this.logger.error(`Unhandled promise rejection: ${e}`,{reason:e,promise:r})}),new Promise((e,r)=>{this.server=this.app.listen(this.config.port,i=>{i?(this.logger.error(`Error starting server: ${i}`),r(i)):(this.logger.debug(`Server started on port ${this.config.port}`),e())})})}stop(){this.app.removeAllListeners(),this.server?.close(),this.logger.debug("Server stopped")}loadPlugin(s){let o,t;if(/:/.test(s)){let e=s.substring(s.indexOf(":")+1);if(s=s.substring(0,s.indexOf(":")),/:/.test(e)){o=e.substring(0,e.indexOf(":")),o||(o=void 0),e=e.substring(e.indexOf(":")+1);try{t=JSON.parse(e),"object"!=typeof t?(this.logger.error(`Overrides for plugin ${s} are not valid: ${e}`),t=void 0):this.logger.debug(`Overrides for plugin ${o||s}: ${e}`)}catch(r){this.logger.error(`Failed to parse overrides for plugin ${s}: ${r}, using ${e}`),t=void 0}}else o=e,o||(o=void 0)}else o=void 0;return this.logger.debug(`Loading plugin ${s}`),s.startsWith("core/")&&(s=s.replace("core/",r(i(e(import.meta.url)),"..","plugins")+"/").replace(/$/,".js")),new Promise((e,r)=>{import(s).then(async i=>{let n;if(i&&i.default&&(i=i.default),t){const e={...this.config,...t};n=new i({...this,config:e})}else n=new i(this);if(!n)return r(new Error(`Unable to load ${s}`));e(await this.registerPlugin(s,n,o)),this.logger.debug(`Plugin ${n.name} loaded`)}).catch(e=>r(new Error(`Failed to load plugin ${s}: ${e}`)))})}async registerPlugin(e,r,i){if(r.name||(r.name=e),i&&(r.name=i),this.loadedPlugins[r.name])return this.logger.info(`Plugin ${e} already loaded as ${r.name}`),!1;if(this.logger.debug(`Registering plugin ${e} as ${r.name}`),r.dependencies)for(const e in r.dependencies)this.loadedPlugins[e]||(this.logger.debug(`Plugin ${r.name} depends on ${e}, loading it first`),await this.loadPlugin(r.dependencies[e]));if(r.api&&(this.logger.debug(`Plugin ${r.name} has API, registering it`),await r.api(this.app),this._errorMiddlewareSetup&&this.setupErrorMiddleware()),r.hooks)for(const e in r.hooks){this.logger.debug(`Plugin ${r.name} has hook ${e}, registering it`);const i=r.hooks[e];if(this.hooks[e]||(this.hooks[e]=[]),!i||"function"!=typeof i)throw new Error(`Plugin ${r.name}: hook ${e} is invalid`);this.hooks[e].push(i)}return this.loadedPlugins[r.name]=r,!0}}export{DM};
1
+ import{fileURLToPath as e}from"url";import{join as r,dirname as i}from"path";import s from"express";import o from"body-parser";import{parseConfig as t}from"../lib/parseConfig.js";import n from"../config/args.js";import a from"../lib/ldapActions.js";import{buildLogger as l}from"../logger/winston.js";import{setLogger as d}from"../lib/expressFormatedResponses.js";import g from"../plugins/priority.json.js";export{asyncHandler,escapeDnValue,escapeLdapFilter,escapeRegex,getCompiledRegex,getParentDn,getRdn,isChildOf,isDnInBranch,launchHooks,launchHooksChained,normalizeDn,parseDn,transformSchemas,unescapeDnValue,validateDnValue}from"../lib/utils.js";export{BadGatewayError,BadRequestError,ConflictError,ForbiddenError,GatewayTimeoutError,HttpError,NotFoundError,ServiceUnavailableError,TooManyRequestsError,UnauthorizedError,UriTooLongError}from"../lib/errors.js";class DM{constructor(){this.hooks={},this.loadedPlugins={},this._errorMiddlewareSetup=!1,this.config=t(n),this.app=s(),this.app.use(o.json()),this.app.use(o.urlencoded({extended:!0})),this.logger=l(this.config),this.ldap=new a(this),d(this.logger),this.operationSequence=0;const e=[];if(this.config.plugin){const r=this.config.plugin.find(e=>e.includes("configApi"));let i=this.config.plugin.filter(e=>!e.includes("configApi"));const s=(async()=>{for(const e of g)i.includes(e)&&(i=i.filter(r=>r!==e),await this.loadPlugin(e))})();e.push(s),e.push(s.then(async()=>{const e=i.map(e=>this.loadPlugin(e));await Promise.all(e)})),r&&e.push(Promise.all(e).then(()=>this.loadPlugin(r)))}this.ready=new Promise((r,i)=>{e.length>0?Promise.all(e).then(()=>{this.setupErrorMiddleware(),r()}).catch(e=>i(new Error("Error loading plugins: "+e))):(this.setupErrorMiddleware(),r())})}setupErrorMiddleware(){if(this._errorMiddlewareSetup&&this._errorMiddleware){const e=this.app._router?.stack;if(e){const r=e.findIndex(e=>e.handle===this._errorMiddleware);-1!==r&&e.splice(r,1)}}this._errorMiddleware=(e,r,i,s)=>{let o="statusCode"in e?e.statusCode:500,t=e.message;if(500===o&&/\[authz-forbidden\]/.test(e.message)?(o=403,t="Token does not have permission on this branch"):403===o&&/\[authz-forbidden\]/.test(e.message)&&(t="Token does not have permission on this branch"),o>=400&&o<500)return this.logger.warn(`Client error ${o} in request ${r.method} ${r.path}: ${t}`),i.headersSent?void 0:i.status(o).json({error:t});this.logger.error(`Server error ${o} in request ${r.method} ${r.path}: ${e.message}`,{stack:e.stack,url:r.url,method:r.method}),i.headersSent||i.status(o).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,r)=>{this.logger.error(`Unhandled promise rejection: ${e}`,{reason:e,promise:r})}),new Promise((e,r)=>{this.server=this.app.listen(this.config.port,i=>{i?(this.logger.error(`Error starting server: ${i}`),r(i)):(this.logger.debug(`Server started on port ${this.config.port}`),e())})})}stop(){this.app.removeAllListeners(),this.server?.close(),this.logger.debug("Server stopped")}loadPlugin(s){let o,t;if(/:/.test(s)){let e=s.substring(s.indexOf(":")+1);if(s=s.substring(0,s.indexOf(":")),/:/.test(e)){o=e.substring(0,e.indexOf(":")),o||(o=void 0),e=e.substring(e.indexOf(":")+1);try{t=JSON.parse(e),"object"!=typeof t?(this.logger.error(`Overrides for plugin ${s} are not valid: ${e}`),t=void 0):this.logger.debug(`Overrides for plugin ${o||s}: ${e}`)}catch(r){this.logger.error(`Failed to parse overrides for plugin ${s}: ${r}, using ${e}`),t=void 0}}else o=e,o||(o=void 0)}else o=void 0;return this.logger.debug(`Loading plugin ${s}`),s.startsWith("core/")&&(s=s.replace("core/",r(i(e(import.meta.url)),"..","plugins")+"/").replace(/$/,".js")),new Promise((e,r)=>{import(s).then(async i=>{let n;if(i&&i.default&&(i=i.default),t){const e={...this.config,...t};n=new i({...this,config:e})}else n=new i(this);if(!n)return r(new Error(`Unable to load ${s}`));e(await this.registerPlugin(s,n,o)),this.logger.debug(`Plugin ${n.name} loaded`)}).catch(e=>r(new Error(`Failed to load plugin ${s}: ${e}`)))})}async registerPlugin(e,r,i){if(r.name||(r.name=e),i&&(r.name=i),this.loadedPlugins[r.name])return this.logger.info(`Plugin ${e} already loaded as ${r.name}`),!1;if(this.logger.debug(`Registering plugin ${e} as ${r.name}`),r.dependencies)for(const e in r.dependencies)this.loadedPlugins[e]||(this.logger.debug(`Plugin ${r.name} depends on ${e}, loading it first`),await this.loadPlugin(r.dependencies[e]));if(r.api&&(this.logger.debug(`Plugin ${r.name} has API, registering it`),await r.api(this.app),this._errorMiddlewareSetup&&this.setupErrorMiddleware()),r.hooks)for(const e in r.hooks){this.logger.debug(`Plugin ${r.name} has hook ${e}, registering it`);const i=r.hooks[e];if(this.hooks[e]||(this.hooks[e]=[]),!i||"function"!=typeof i)throw new Error(`Plugin ${r.name}: hook ${e} is invalid`);this.hooks[e].push(i)}return this.loadedPlugins[r.name]=r,!0}}export{DM};
@@ -1 +1 @@
1
- import r from"../../abstract/plugin.js";import{getParentDn as e}from"../utils.js";class AuthzBase extends r{constructor(){super(...arguments),this.roles=["authz"],this.hooks={ldapmodifyrequest:async([r,e,s,t])=>{if(this.shouldSkipAuthorization(t))return[r,e,s,t];const i=await this.resolveUser(t.user);if(!i)return this.logger.warn(`User ${t.user} could not be resolved`),[r,e,s,t];const o=this.config.ldap_organization_link_attribute;if(o&&e.replace?.[o]){try{const e=await this.server.ldap.search({paged:!1,scope:"base",attributes:[o]},r);if(e.searchEntries.length>0){const r=e.searchEntries[0][o],s=Array.isArray(r)?String(r[0]):String(r);if(!(await this.getUserPermissions(i,s)).read)throw new Error(`User ${t.user} does not have read permission for source branch ${s}`)}}catch(e){const s=this.extractBranchDn(r);if(!(await this.getUserPermissions(i,s)).read)throw new Error(`User ${t.user} does not have read permission for source branch ${s}`)}const s=e.replace[o],n=Array.isArray(s)?String(s[0]):String(s);if(!(await this.getUserPermissions(i,n)).write)throw new Error(`User ${t.user} does not have write permission for destination branch ${n}`)}else{const e=this.extractBranchDn(r);if(!(await this.getUserPermissions(i,e)).write)throw new Error(`User ${t.user} does not have write permission for branch ${e}`)}return[r,e,s,t]},ldapaddrequest:async([r,e,s])=>{if(this.shouldSkipAuthorization(s))return[r,e,s];const t=await this.resolveUser(s.user);if(!t)return this.logger.warn(`User ${s.user} could not be resolved`),[r,e,s];let i;const o=this.config.ldap_organization_link_attribute;if(o&&e[o]){const r=e[o];i=Array.isArray(r)?String(r[0]):String(r)}else i=this.extractBranchDn(r);if(!(await this.getUserPermissions(t,i)).write)throw new Error(`User ${s.user} does not have write permission for branch ${i}`);return[r,e,s]},ldapsearchrequest:async([r,e,s])=>{if(this.shouldSkipAuthorization(s))return[r,e,s];const t=await this.resolveUser(s.user);if(!t)return this.logger.warn(`User ${s.user} could not be resolved`),[r,e,s];if(r===this.config.ldap_top_organization&&"base"===e.scope)return[r,e,s];if(!(await this.getUserPermissions(t,r)).read)throw new Error(`User ${s.user} does not have read permission for branch ${r}`);return[r,e,s]},getOrganisationTop:async([r,e])=>{if(this.shouldSkipAuthorization(r))return[r,e];const s=await this.resolveUser(r.user);if(!s)return[r,e];const t=await this.getAuthorizedBranches(s);if(t.length>0){const e=[];for(const s of t)try{const t=await this.server.ldap.search({paged:!1,scope:"base"},s,r);1===t.searchEntries.length&&e.push(t.searchEntries[0])}catch(r){this.logger.warn(`Failed to fetch authorized branch ${s}: ${r}`)}if(1===e.length)return[r,e[0]];if(e.length>1)return[r,e[0]]}return[r,e]},ldaprenamerequest:async([r,e,s])=>{if(this.shouldSkipAuthorization(s))return[r,e,s];const t=await this.resolveUser(s.user);if(!t)return this.logger.warn(`User ${s.user} could not be resolved`),[r,e,s];const i=this.extractBranchDn(r),o=this.extractBranchDn(e);if(!(await this.getUserPermissions(t,i)).read)throw new Error(`User ${s.user} does not have read permission for source branch ${i}`);if(!(await this.getUserPermissions(t,o)).write)throw new Error(`User ${s.user} does not have write permission for destination branch ${o}`);return[r,e,s]}}}extractBranchDn(r){return e(r)}shouldSkipAuthorization(r){return!r?.user}}export{AuthzBase as default};
1
+ import r from"../../abstract/plugin.js";import{getParentDn as e}from"../utils.js";class AuthzBase extends r{constructor(){super(...arguments),this.roles=["authz"],this.hooks={ldapmodifyrequest:async([r,e,t,s])=>{if(this.shouldSkipAuthorization(s))return[r,e,t,s];const i=await this.resolveUser(s.user);if(!i)return this.logger.warn(`User ${s.user} could not be resolved`),[r,e,t,s];const o=this.config.ldap_organization_link_attribute;if(o&&e.replace?.[o]){try{const e=await this.server.ldap.search({paged:!1,scope:"base",attributes:[o]},r);if(e.searchEntries.length>0){const r=e.searchEntries[0][o],t=Array.isArray(r)?String(r[0]):String(r);if(!(await this.getUserPermissions(i,t)).read)throw new Error(`[authz-forbidden] User ${s.user} does not have read permission for source branch ${t}`)}}catch(e){const t=this.extractBranchDn(r);if(!(await this.getUserPermissions(i,t)).read)throw new Error(`[authz-forbidden] User ${s.user} does not have read permission for source branch ${t}`)}const t=e.replace[o],n=Array.isArray(t)?String(t[0]):String(t);if(!(await this.getUserPermissions(i,n)).write)throw new Error(`[authz-forbidden] User ${s.user} does not have write permission for destination branch ${n}`)}else{const e=this.extractBranchDn(r);if(!(await this.getUserPermissions(i,e)).write)throw new Error(`[authz-forbidden] User ${s.user} does not have write permission for branch ${e}`)}return[r,e,t,s]},ldapaddrequest:async([r,e,t])=>{if(this.shouldSkipAuthorization(t))return[r,e,t];const s=await this.resolveUser(t.user);if(!s)return this.logger.warn(`User ${t.user} could not be resolved`),[r,e,t];let i;const o=this.config.ldap_organization_link_attribute;if(o&&e[o]){const r=e[o];i=Array.isArray(r)?String(r[0]):String(r)}else i=this.extractBranchDn(r);if(!(await this.getUserPermissions(s,i)).write)throw new Error(`[authz-forbidden] User ${t.user} does not have write permission for branch ${i}`);return[r,e,t]},ldapsearchrequest:async([r,e,t])=>{if(this.shouldSkipAuthorization(t))return[r,e,t];const s=await this.resolveUser(t.user);if(!s)return this.logger.warn(`User ${t.user} could not be resolved`),[r,e,t];if(r===this.config.ldap_top_organization&&"base"===e.scope)return[r,e,t];if(!(await this.getUserPermissions(s,r)).read)throw new Error(`[authz-forbidden] User ${t.user} does not have read permission for branch ${r}`);return[r,e,t]},getOrganisationTop:async([r,e])=>{if(this.shouldSkipAuthorization(r))return[r,e];const t=await this.resolveUser(r.user);if(!t)return[r,e];const s=await this.getAuthorizedBranches(t);if(s.length>0){const e=[];for(const t of s)try{const s=await this.server.ldap.search({paged:!1,scope:"base"},t,r);1===s.searchEntries.length&&e.push(s.searchEntries[0])}catch(r){this.logger.warn(`Failed to fetch authorized branch ${t}: ${r}`)}if(1===e.length)return[r,e[0]];if(e.length>1)return[r,e[0]]}return[r,e]},ldaprenamerequest:async([r,e,t])=>{if(this.shouldSkipAuthorization(t))return[r,e,t];const s=await this.resolveUser(t.user);if(!s)return this.logger.warn(`User ${t.user} could not be resolved`),[r,e,t];const i=this.extractBranchDn(r),o=this.extractBranchDn(e);if(!(await this.getUserPermissions(s,i)).read)throw new Error(`[authz-forbidden] User ${t.user} does not have read permission for source branch ${i}`);if(!(await this.getUserPermissions(s,o)).write)throw new Error(`[authz-forbidden] User ${t.user} does not have write permission for destination branch ${o}`);return[r,e,t]},ldapdeleterequest:async([r,e])=>{if(this.shouldSkipAuthorization(e))return[r,e];const t=await this.resolveUser(e.user);if(!t)return this.logger.warn(`User ${e.user} could not be resolved`),[r,e];const s=Array.isArray(r)?r:[r];for(const r of s){const s=this.extractBranchDn(r);if(!(await this.getUserPermissions(t,s)).delete)throw new Error(`[authz-forbidden] User ${e.user} does not have delete permission for branch ${s}`)}return[r,e]}}}extractBranchDn(r){return e(r)}shouldSkipAuthorization(r){return!r?.user}}export{AuthzBase as default};
@@ -1 +1 @@
1
- import{Client as e,Attribute as t,Change as i}from"ldapts";import{LRUCache as n}from"lru-cache";import o from"p-limit";import{launchHooksChained as s,launchHooks as r,escapeDnValue as a}from"./utils.js";const c={scope:"sub",filter:"(objectClass=*)",attributes:["*"],sizeLimit:0,timeLimit:10,paged:{pageSize:100}};class ldapActions{constructor(e){this.connectionPool=[],this.currentUrlIndex=0,this.waitingResolvers=[],this.availableConnections=[],this.isCleaningUp=!1,this.parent=e,this.logger=e.logger,this.config=e.config,this.poolSize=this.config.ldap_pool_size||5,this.connectionTtl=1e3*(this.config.ldap_connection_ttl||60),this.logger.info(`LDAP connection pool initialized: size=${this.poolSize}, ttl=${this.connectionTtl/1e3}s`);const t=this.config.ldap_concurrency||10;this.queryLimit=o(t),this.logger.info(`Global LDAP query concurrency limit set to ${t}`);const i="string"==typeof this.config.ldap_cache_max?parseInt(this.config.ldap_cache_max,10)||1e3:this.config.ldap_cache_max??1e3,s=1e3*(this.config.ldap_cache_ttl||300);if(this.searchCache=new n({max:i,ttl:s,updateAgeOnGet:!1,updateAgeOnHas:!1}),this.logger.info(`LDAP search cache initialized: max=${i}, ttl=${s/1e3}s`),this.attrSignatureCache=new n({max:1e3}),!e.config.ldap_url||0===e.config.ldap_url.length)throw new Error("LDAP URL is not defined");if(!e.config.ldap_dn)throw new Error("LDAP DN is not defined");if(!e.config.ldap_pwd)throw new Error("LDAP password is not defined");e.config.ldap_base?this.base=e.config.ldap_base:(this.base=e.config.ldap_dn.split(",",2)[1],this.logger.warn(`LDAP base is not defined, using "${this.base}"`)),this.ldapUrls=e.config.ldap_url,this.logger.info(`LDAP failover configured with ${this.ldapUrls.length} URL(s): ${this.ldapUrls.join(", ")}`),this.options={url:this.ldapUrls[0],timeout:0,connectTimeout:0,strictDN:!1},this.ldapUrls[0].startsWith("ldaps://")&&(this.options.tlsOptions={minVersion:"TLSv1.2"}),this.dn=e.config.ldap_dn,this.pwd=e.config.ldap_pwd}async createConnection(){const t=[];for(let i=0;i<this.ldapUrls.length;i++){const n=(this.currentUrlIndex+i)%this.ldapUrls.length,o=this.ldapUrls[n];try{this.logger.debug(`Attempting connection to ${o}`);const t={...this.options,url:o,tlsOptions:o.startsWith("ldaps://")?{minVersion:"TLSv1.2"}:void 0},i=new e(t);return await i.bind(this.dn,this.pwd),n!==this.currentUrlIndex&&(this.logger.info(`LDAP failover: switched from ${this.ldapUrls[this.currentUrlIndex]} to ${o}`),this.currentUrlIndex=n),i}catch(e){this.logger.warn(`Failed to connect to ${o}: ${String(e)}`),t.push(e)}}throw this.logger.error(`LDAP connection failed for all ${this.ldapUrls.length} URL(s)`),new Error(`LDAP connection failed for all URLs: ${t.map(e=>e.message).join(", ")}`)}cleanupExpiredConnections(){if(!this.isCleaningUp){this.isCleaningUp=!0;try{const e=Date.now(),t=[];for(let i=this.availableConnections.length-1;i>=0;i--){const n=this.availableConnections[i];if(e-n.createdAt>this.connectionTtl){t.push(n),this.availableConnections.splice(i,1);const e=this.connectionPool.indexOf(n);-1!==e&&this.connectionPool.splice(e,1)}}for(const e of t)e.client.unbind().catch(e=>{this.logger.debug(`Error unbinding expired connection: ${String(e)}`)});t.length>0&&this.logger.debug(`Cleaned up ${t.length} expired LDAP connections`)}finally{this.isCleaningUp=!1}}}async acquireConnection(){if(this.cleanupExpiredConnections(),this.availableConnections.length>0){const e=this.availableConnections.pop();return e.inUse=!0,this.logger.debug("Reusing pooled LDAP connection"),e}if(this.connectionPool.length<this.poolSize){const e={client:await this.createConnection(),createdAt:Date.now(),inUse:!0};return this.connectionPool.push(e),this.logger.debug(`Created new LDAP connection (pool: ${this.connectionPool.length}/${this.poolSize})`),e}return this.logger.debug("LDAP connection pool full, waiting for available connection"),new Promise((e,t)=>{const i=globalThis.setTimeout(()=>{const e=this.waitingResolvers.indexOf(resolveWrapper);-1!==e&&this.waitingResolvers.splice(e,1),t(new Error("LDAP connection pool timeout after 30s"))},3e4),resolveWrapper=t=>{globalThis.clearTimeout(i),e(t)};this.waitingResolvers.push(resolveWrapper)})}releaseConnection(e){if(Date.now()-e.createdAt>this.connectionTtl){const t=this.connectionPool.indexOf(e);return-1!==t&&this.connectionPool.splice(t,1),e.client.unbind().catch(()=>{}),void this.logger.debug("Released expired LDAP connection (discarded)")}if(this.waitingResolvers.length>0){const t=this.waitingResolvers.shift();return this.logger.debug("Released LDAP connection to waiting request"),void t(e)}e.inUse=!1,this.availableConnections.push(e),this.logger.debug("Released LDAP connection back to pool")}getAttributeSignature(e){if(!e||0===e.length)return"*";const t=e.join("|");let i=this.attrSignatureCache.get(t);return i||(i=[...e].sort().join(","),this.attrSignatureCache.set(t,i)),i}getCacheKey(e,t){const i=this.getAttributeSignature(t.attributes),n="string"==typeof t.filter?t.filter:t.filter?t.filter.toString():"(objectClass=*)";return`${e}:${t.scope||"sub"}:${n}:${i}`}invalidateCache(e){for(const t of this.searchCache.keys())t.startsWith(e)&&this.searchCache.delete(t)}async search(e,t=this.base,i){let n={...c,...e};if(n=await s(this.parent.hooks.ldapsearchopts,n),[t,n]=await s(this.parent.hooks.ldapsearchrequest,[t,n,i]),!n.paged&&"base"===n.scope){const e=this.getCacheKey(t,n),i=this.searchCache.get(e);if(i)return this.logger.debug(`LDAP search cache hit: ${e}`),i}const o=await this.acquireConnection();try{let e=n.paged?o.client.searchPaginated(t,n):o.client.search(t,n);if(e=await s(this.parent.hooks.ldapsearchresult,e),!n.paged&&"base"===n.scope&&e instanceof Promise){const i=await e,o=this.getCacheKey(t,n);return this.searchCache.set(o,i),this.logger.debug(`LDAP search cached: ${o}`),i}return n.paged?this.wrapPaginatedSearch(e,o):e}finally{n.paged||this.releaseConnection(o)}}async*wrapPaginatedSearch(e,t){try{for await(const t of e)yield t}finally{this.releaseConnection(t)}}async add(e,i,n){e=this.setDn(e),i.objectClass&&0!==i.objectClass.length||!this.config.user_class||(i.objectClass=this.config.user_class);const o={};for(const[e,t]of Object.entries(i))Buffer.isBuffer(t)?o[e]=t.toString():Array.isArray(t)&&t.length>0&&Buffer.isBuffer(t[0])?o[e]=t.map(e=>e.toString()):o[e]=t;[e,i]=await s(this.parent.hooks.ldapaddrequest,[e,o,n]);const a=[];for(const[e,i]of Object.entries(o)){const n=Array.isArray(i)?i:[i];a.push(new t({type:e,values:n}))}const c=await this.acquireConnection();try{return await c.client.add(e,a),this.invalidateCache(e),r(this.parent.hooks.ldapadddone,[e,i]).catch(e=>{this.logger.error(`Hook ldapadddone failed: ${String(e)}`)}),!0}catch(e){throw new Error(`LDAP add error: ${e}`)}finally{this.releaseConnection(c)}}async modify(e,n,o){e=this.setDn(e);const a=[],c=this.opNumber();if([e,n]=await s(this.parent.hooks.ldapmodifyrequest,[e,n,c,o]),n.add)for(const[e,o]of Object.entries(n.add))a.push(new i({operation:"add",modification:new t({type:e,values:Array.isArray(o)?o:[o]})}));if(n.replace)for(const[e,o]of Object.entries(n.replace))a.push(new i({operation:"replace",modification:new t({type:e,values:Array.isArray(o)?o:[o]})}));if(n.delete)if(Array.isArray(n.delete))for(const e of n.delete)e&&a.push(new i({operation:"delete",modification:new t({type:e,values:[]})}));else for(const[e,o]of Object.entries(n.delete)){const n=new i({operation:"delete",modification:new t(o?{type:e,values:Array.isArray(o)?o:[o]}:{type:e})});a.push(n)}if(0===a.length)return this.logger.error("No changes to apply"),r(this.parent.hooks.ldapmodifydone,[e,{},c]).catch(e=>{this.logger.error(`Hook ldapmodifydone failed: ${String(e)}`)}),!1;{const t=await this.acquireConnection();try{return await t.client.modify(e,a),this.invalidateCache(e),r(this.parent.hooks.ldapmodifydone,[e,n,c]).catch(e=>{this.logger.error(`Hook ldapmodifydone failed: ${String(e)}`)}),!0}catch(t){throw this.logger.warn(`Changes that failed: ${e}, ${JSON.stringify(a)}`),new Error(`LDAP modify error: ${t}`)}finally{this.releaseConnection(t)}}}async rename(e,t,i){e=this.setDn(e),t=this.setDn(t),[e,t]=await s(this.parent.hooks.ldaprenamerequest,[e,t,i]);const n=await this.acquireConnection();try{return await n.client.modifyDN(e,t),r(this.parent.hooks.ldaprenamedone,[e,t]).catch(e=>{this.logger.error(`Hook ldaprenamedone failed: ${String(e)}`)}),!0}catch(e){throw new Error(`LDAP rename error: ${e}`)}finally{this.releaseConnection(n)}}async move(e,t){e=this.setDn(e),t=this.setDn(t);const i=await this.acquireConnection();try{return await i.client.modifyDN(e,t),this.logger.debug(`LDAP move: ${e} -> ${t}`),!0}catch(e){throw new Error(`LDAP move error: ${e}`)}finally{this.releaseConnection(i)}}async delete(e){e=Array.isArray(e)?e.map(e=>this.setDn(e)):this.setDn(e),Array.isArray(e)||(e=[e]),e=await s(this.parent?.hooks.ldapdeleterequest,e);const t=await this.acquireConnection();try{for(const i of e){try{await t.client.del(i),this.invalidateCache(i)}catch(e){throw new Error(`LDAP delete error: ${e}`)}r(this.parent.hooks.ldapdeletedone,i).catch(e=>{this.logger.error(`Hook ldapdeletedone failed: ${String(e)}`)})}return!0}finally{this.releaseConnection(t)}}setDn(e){return/=/.test(e)?/,/.test(e)||(e+=`,${this.base}`):e=`${this.config.ldap_user_main_attribute}=${a(e)},${this.base}`,e}opNumber(){return this.parent.operationSequence++}}export{ldapActions as default};
1
+ import{Client as e,Attribute as t,Change as i}from"ldapts";import{LRUCache as n}from"lru-cache";import o from"p-limit";import{launchHooksChained as s,launchHooks as r,escapeDnValue as a}from"./utils.js";const c={scope:"sub",filter:"(objectClass=*)",attributes:["*"],sizeLimit:0,timeLimit:10,paged:{pageSize:100}};class ldapActions{constructor(e){this.connectionPool=[],this.currentUrlIndex=0,this.waitingResolvers=[],this.availableConnections=[],this.isCleaningUp=!1,this.parent=e,this.logger=e.logger,this.config=e.config,this.poolSize=this.config.ldap_pool_size||5,this.connectionTtl=1e3*(this.config.ldap_connection_ttl||60),this.logger.info(`LDAP connection pool initialized: size=${this.poolSize}, ttl=${this.connectionTtl/1e3}s`);const t=this.config.ldap_concurrency||10;this.queryLimit=o(t),this.logger.info(`Global LDAP query concurrency limit set to ${t}`);const i="string"==typeof this.config.ldap_cache_max?parseInt(this.config.ldap_cache_max,10)||1e3:this.config.ldap_cache_max??1e3,s=1e3*(this.config.ldap_cache_ttl||300);if(this.searchCache=new n({max:i,ttl:s,updateAgeOnGet:!1,updateAgeOnHas:!1}),this.logger.info(`LDAP search cache initialized: max=${i}, ttl=${s/1e3}s`),this.attrSignatureCache=new n({max:1e3}),!e.config.ldap_url||0===e.config.ldap_url.length)throw new Error("LDAP URL is not defined");if(!e.config.ldap_dn)throw new Error("LDAP DN is not defined");if(!e.config.ldap_pwd)throw new Error("LDAP password is not defined");e.config.ldap_base?this.base=e.config.ldap_base:(this.base=e.config.ldap_dn.split(",",2)[1],this.logger.warn(`LDAP base is not defined, using "${this.base}"`)),this.ldapUrls=e.config.ldap_url,this.logger.info(`LDAP failover configured with ${this.ldapUrls.length} URL(s): ${this.ldapUrls.join(", ")}`),this.options={url:this.ldapUrls[0],timeout:0,connectTimeout:0,strictDN:!1},this.ldapUrls[0].startsWith("ldaps://")&&(this.options.tlsOptions={minVersion:"TLSv1.2"}),this.dn=e.config.ldap_dn,this.pwd=e.config.ldap_pwd}async createConnection(){const t=[];for(let i=0;i<this.ldapUrls.length;i++){const n=(this.currentUrlIndex+i)%this.ldapUrls.length,o=this.ldapUrls[n];try{this.logger.debug(`Attempting connection to ${o}`);const t={...this.options,url:o,tlsOptions:o.startsWith("ldaps://")?{minVersion:"TLSv1.2"}:void 0},i=new e(t);return await i.bind(this.dn,this.pwd),n!==this.currentUrlIndex&&(this.logger.info(`LDAP failover: switched from ${this.ldapUrls[this.currentUrlIndex]} to ${o}`),this.currentUrlIndex=n),i}catch(e){this.logger.warn(`Failed to connect to ${o}: ${String(e)}`),t.push(e)}}throw this.logger.error(`LDAP connection failed for all ${this.ldapUrls.length} URL(s)`),new Error(`LDAP connection failed for all URLs: ${t.map(e=>e.message).join(", ")}`)}cleanupExpiredConnections(){if(!this.isCleaningUp){this.isCleaningUp=!0;try{const e=Date.now(),t=[];for(let i=this.availableConnections.length-1;i>=0;i--){const n=this.availableConnections[i];if(e-n.createdAt>this.connectionTtl){t.push(n),this.availableConnections.splice(i,1);const e=this.connectionPool.indexOf(n);-1!==e&&this.connectionPool.splice(e,1)}}for(const e of t)e.client.unbind().catch(e=>{this.logger.debug(`Error unbinding expired connection: ${String(e)}`)});t.length>0&&this.logger.debug(`Cleaned up ${t.length} expired LDAP connections`)}finally{this.isCleaningUp=!1}}}async acquireConnection(){if(this.cleanupExpiredConnections(),this.availableConnections.length>0){const e=this.availableConnections.pop();return e.inUse=!0,this.logger.debug("Reusing pooled LDAP connection"),e}if(this.connectionPool.length<this.poolSize){const e={client:await this.createConnection(),createdAt:Date.now(),inUse:!0};return this.connectionPool.push(e),this.logger.debug(`Created new LDAP connection (pool: ${this.connectionPool.length}/${this.poolSize})`),e}return this.logger.debug("LDAP connection pool full, waiting for available connection"),new Promise((e,t)=>{const i=globalThis.setTimeout(()=>{const e=this.waitingResolvers.indexOf(resolveWrapper);-1!==e&&this.waitingResolvers.splice(e,1),t(new Error("LDAP connection pool timeout after 30s"))},3e4),resolveWrapper=t=>{globalThis.clearTimeout(i),e(t)};this.waitingResolvers.push(resolveWrapper)})}releaseConnection(e){if(Date.now()-e.createdAt>this.connectionTtl){const t=this.connectionPool.indexOf(e);return-1!==t&&this.connectionPool.splice(t,1),e.client.unbind().catch(()=>{}),void this.logger.debug("Released expired LDAP connection (discarded)")}if(this.waitingResolvers.length>0){const t=this.waitingResolvers.shift();return this.logger.debug("Released LDAP connection to waiting request"),void t(e)}e.inUse=!1,this.availableConnections.push(e),this.logger.debug("Released LDAP connection back to pool")}getAttributeSignature(e){if(!e||0===e.length)return"*";const t=e.join("|");let i=this.attrSignatureCache.get(t);return i||(i=[...e].sort().join(","),this.attrSignatureCache.set(t,i)),i}getCacheKey(e,t){const i=this.getAttributeSignature(t.attributes),n="string"==typeof t.filter?t.filter:t.filter?t.filter.toString():"(objectClass=*)";return`${e}:${t.scope||"sub"}:${n}:${i}`}invalidateCache(e){for(const t of this.searchCache.keys())t.startsWith(e)&&this.searchCache.delete(t)}async search(e,t=this.base,i){let n={...c,...e};if(n=await s(this.parent.hooks.ldapsearchopts,n),[t,n]=await s(this.parent.hooks.ldapsearchrequest,[t,n,i]),!n.paged&&"base"===n.scope){const e=this.getCacheKey(t,n),i=this.searchCache.get(e);if(i)return this.logger.debug(`LDAP search cache hit: ${e}`),i}const o=await this.acquireConnection();try{let e=n.paged?o.client.searchPaginated(t,n):o.client.search(t,n);if(e=await s(this.parent.hooks.ldapsearchresult,e),!n.paged&&"base"===n.scope&&e instanceof Promise){const i=await e,o=this.getCacheKey(t,n);return this.searchCache.set(o,i),this.logger.debug(`LDAP search cached: ${o}`),i}return n.paged?this.wrapPaginatedSearch(e,o):e}finally{n.paged||this.releaseConnection(o)}}async*wrapPaginatedSearch(e,t){try{for await(const t of e)yield t}finally{this.releaseConnection(t)}}async add(e,i,n){e=this.setDn(e),i.objectClass&&0!==i.objectClass.length||!this.config.user_class||(i.objectClass=this.config.user_class);const o={};for(const[e,t]of Object.entries(i))Buffer.isBuffer(t)?o[e]=t.toString():Array.isArray(t)&&t.length>0&&Buffer.isBuffer(t[0])?o[e]=t.map(e=>e.toString()):o[e]=t;[e,i]=await s(this.parent.hooks.ldapaddrequest,[e,o,n]);const a=[];for(const[e,i]of Object.entries(o)){const n=Array.isArray(i)?i:[i];a.push(new t({type:e,values:n}))}const c=await this.acquireConnection();try{return await c.client.add(e,a),this.invalidateCache(e),r(this.parent.hooks.ldapadddone,[e,i]).catch(e=>{this.logger.error(`Hook ldapadddone failed: ${String(e)}`)}),!0}catch(e){throw new Error(`LDAP add error: ${e}`)}finally{this.releaseConnection(c)}}async modify(e,n,o){e=this.setDn(e);const a=[],c=this.opNumber();if([e,n]=await s(this.parent.hooks.ldapmodifyrequest,[e,n,c,o]),n.add)for(const[e,o]of Object.entries(n.add))a.push(new i({operation:"add",modification:new t({type:e,values:Array.isArray(o)?o:[o]})}));if(n.replace)for(const[e,o]of Object.entries(n.replace))a.push(new i({operation:"replace",modification:new t({type:e,values:Array.isArray(o)?o:[o]})}));if(n.delete)if(Array.isArray(n.delete))for(const e of n.delete)e&&a.push(new i({operation:"delete",modification:new t({type:e,values:[]})}));else for(const[e,o]of Object.entries(n.delete)){const n=new i({operation:"delete",modification:new t(o?{type:e,values:Array.isArray(o)?o:[o]}:{type:e})});a.push(n)}if(0===a.length)return this.logger.error("No changes to apply"),r(this.parent.hooks.ldapmodifydone,[e,{},c]).catch(e=>{this.logger.error(`Hook ldapmodifydone failed: ${String(e)}`)}),!1;{const t=await this.acquireConnection();try{return await t.client.modify(e,a),this.invalidateCache(e),r(this.parent.hooks.ldapmodifydone,[e,n,c]).catch(e=>{this.logger.error(`Hook ldapmodifydone failed: ${String(e)}`)}),!0}catch(t){throw this.logger.warn(`Changes that failed: ${e}, ${JSON.stringify(a)}`),new Error(`LDAP modify error: ${t}`)}finally{this.releaseConnection(t)}}}async rename(e,t,i){e=this.setDn(e),t=this.setDn(t),[e,t]=await s(this.parent.hooks.ldaprenamerequest,[e,t,i]);const n=await this.acquireConnection();try{return await n.client.modifyDN(e,t),r(this.parent.hooks.ldaprenamedone,[e,t]).catch(e=>{this.logger.error(`Hook ldaprenamedone failed: ${String(e)}`)}),!0}catch(e){throw new Error(`LDAP rename error: ${e}`)}finally{this.releaseConnection(n)}}async move(e,t){e=this.setDn(e),t=this.setDn(t);const i=await this.acquireConnection();try{return await i.client.modifyDN(e,t),this.logger.debug(`LDAP move: ${e} -> ${t}`),!0}catch(e){throw new Error(`LDAP move error: ${e}`)}finally{this.releaseConnection(i)}}async delete(e,t){e=Array.isArray(e)?e.map(e=>this.setDn(e)):this.setDn(e),Array.isArray(e)||(e=[e]),[e]=await s(this.parent?.hooks.ldapdeleterequest,[e,t]);const i=await this.acquireConnection();try{for(const t of e){try{await i.client.del(t),this.invalidateCache(t)}catch(e){throw new Error(`LDAP delete error: ${e}`)}r(this.parent.hooks.ldapdeletedone,t).catch(e=>{this.logger.error(`Hook ldapdeletedone failed: ${String(e)}`)})}return!0}finally{this.releaseConnection(i)}}setDn(e){return/=/.test(e)?/,/.test(e)||(e+=`,${this.base}`):e=`${this.config.ldap_user_main_attribute}=${a(e)},${this.base}`,e}opNumber(){return this.parent.operationSequence++}}export{ldapActions as default};
package/dist/lib/utils.js CHANGED
@@ -1 +1 @@
1
- import{getLogger as e}from"./expressFormatedResponses.js";const t=e(),r=new Map;function getCompiledRegex(e,t){const n=t?`${e}:${t}`:e;let a=r.get(n);return a||(a=new RegExp(e,t),r.set(n,a)),a}function escapeRegex(e){return e.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")}const launchHooks=async(e,...r)=>{if(e)for(const n of e)if(n)try{await n(...r)}catch(e){t.error("Hook error",e)}},launchHooksChained=async(e,t)=>{if(e)for(const r of e)r&&(t=await r(t));return t},transformSchemas=(e,t)=>e.toString().replace(/__(\S+)__/g,(e,r)=>{if(!r||"string"!=typeof r)return e;const n=r.trim().toLowerCase();return t[n]?"object"!=typeof t[n]?t[n]:JSON.stringify(t[n]):e});function escapeLdapFilter(e){return e.replace(/\\/g,"\\5c").replace(/\*/g,"\\2a").replace(/\(/g,"\\28").replace(/\)/g,"\\29").replace(/\0/g,"\\00")}function escapeDnValue(e){return e.replace(/\\/g,"\\\\").replace(/,/g,"\\,").replace(/\+/g,"\\+").replace(/"/g,'\\"').replace(/</g,"\\<").replace(/>/g,"\\>").replace(/;/g,"\\;").replace(/=/g,"\\=").replace(/\0/g,"\\00").replace(/^\s/,"\\ ").replace(/\s$/,"\\ ").replace(/^#/,"\\#")}function unescapeDnValue(e){let t="",r=0;for(;r<e.length;)if("\\"===e[r]&&r+1<e.length){const n=e[r+1];if(/[0-9a-fA-F]/.test(n)&&r+2<e.length){const n=e.substring(r+1,r+3);if(/^[0-9a-fA-F]{2}$/.test(n)){t+=String.fromCharCode(parseInt(n,16)),r+=3;continue}}t+=n,r+=2}else t+=e[r],r++;return t}function validateDnValue(e,t){if(null==e||"string"!=typeof e)throw new Error(`${t} must be a string`);if(0===e.trim().length)throw new Error(`${t} must be a non-empty string`);if(/[\x00-\x1F\x7F]/.test(e))throw new Error(`${t} contains invalid control characters`);if(/[\u200B-\u200F\uFEFF]/.test(e))throw new Error(`${t} contains invalid invisible characters`)}function parseDn(e){const t=[];let r="",n=!1;for(let a=0;a<e.length;a++){const o=e[a];n?(r+=o,n=!1):"\\"===o?(r+=o,n=!0):","===o?(t.push(r.trim()),r=""):r+=o}return r&&t.push(r.trim()),t}function getParentDn(e){const t=parseDn(e);return t.length<=1?e:t.slice(1).join(",")}function getRdn(e){return parseDn(e)[0]||""}function isChildOf(e,t){const r=e.toLowerCase(),n=t.toLowerCase();if(!r.endsWith(n))return!1;if(r.length===n.length)return!1;return r.substring(0,r.length-n.length).endsWith(",")}const asyncHandler=e=>(t,r,n)=>{Promise.resolve(e(t,r,n)).catch(n)};export{asyncHandler,escapeDnValue,escapeLdapFilter,escapeRegex,getCompiledRegex,getParentDn,getRdn,isChildOf,launchHooks,launchHooksChained,parseDn,transformSchemas,unescapeDnValue,validateDnValue};
1
+ import{getLogger as e}from"./expressFormatedResponses.js";const n=e(),t=new Map;function getCompiledRegex(e,n){const r=n?`${e}:${n}`:e;let i=t.get(r);return i||(i=new RegExp(e,n),t.set(r,i)),i}function escapeRegex(e){return e.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")}const launchHooks=async(e,...t)=>{if(e)for(const r of e)if(r)try{await r(...t)}catch(e){n.error("Hook error",e)}},launchHooksChained=async(e,n)=>{if(e)for(const t of e)t&&(n=await t(n));return n},transformSchemas=(e,n)=>e.toString().replace(/__(\S+)__/g,(e,t)=>{if(!t||"string"!=typeof t)return e;const r=t.trim().toLowerCase();return n[r]?"object"!=typeof n[r]?n[r]:JSON.stringify(n[r]):e});function escapeLdapFilter(e){return e.replace(/\\/g,"\\5c").replace(/\*/g,"\\2a").replace(/\(/g,"\\28").replace(/\)/g,"\\29").replace(/\0/g,"\\00")}function escapeDnValue(e){return e.replace(/\\/g,"\\\\").replace(/,/g,"\\,").replace(/\+/g,"\\+").replace(/"/g,'\\"').replace(/</g,"\\<").replace(/>/g,"\\>").replace(/;/g,"\\;").replace(/=/g,"\\=").replace(/\0/g,"\\00").replace(/^\s/,"\\ ").replace(/\s$/,"\\ ").replace(/^#/,"\\#")}function unescapeDnValue(e){let n="",t=0;for(;t<e.length;)if("\\"===e[t]&&t+1<e.length){const r=e[t+1];if(/[0-9a-fA-F]/.test(r)&&t+2<e.length){const r=e.substring(t+1,t+3);if(/^[0-9a-fA-F]{2}$/.test(r)){n+=String.fromCharCode(parseInt(r,16)),t+=3;continue}}n+=r,t+=2}else n+=e[t],t++;return n}function validateDnValue(e,n){if(null==e||"string"!=typeof e)throw new Error(`${n} must be a string`);if(0===e.trim().length)throw new Error(`${n} must be a non-empty string`);if(/[\x00-\x1F\x7F]/.test(e))throw new Error(`${n} contains invalid control characters`);if(/[\u200B-\u200F\uFEFF]/.test(e))throw new Error(`${n} contains invalid invisible characters`)}function parseDn(e){const n=[];let t="",r=!1;for(let i=0;i<e.length;i++){const o=e[i];r?(t+=o,r=!1):"\\"===o?(t+=o,r=!0):","===o?(n.push(t.trim()),t=""):t+=o}return t&&n.push(t.trim()),n}function getParentDn(e){const n=parseDn(e);return n.length<=1?e:n.slice(1).join(",")}function getRdn(e){return parseDn(e)[0]||""}function isChildOf(e,n){const t=e.toLowerCase(),r=n.toLowerCase();if(!t.endsWith(r))return!1;if(t.length===r.length)return!1;return t.substring(0,t.length-r.length).endsWith(",")}function normalizeDn(e){return parseDn(e).map(e=>e.split("+").map(e=>{const n=e.indexOf("=");return(-1===n?e.trim():`${e.slice(0,n).trim()}=${e.slice(n+1).trim()}`).toLowerCase()}).sort().join("+")).filter(e=>e.length>0).join(",")}function isDnInBranch(e,n){const t=normalizeDn(n).split(",").filter(Boolean);if(0===t.length)return!1;const r=normalizeDn(e).split(",").filter(Boolean);if(r.length<t.length)return!1;return r.slice(r.length-t.length).every((e,n)=>e===t[n])}const asyncHandler=e=>(n,t,r)=>{Promise.resolve(e(n,t,r)).catch(r)};export{asyncHandler,escapeDnValue,escapeLdapFilter,escapeRegex,getCompiledRegex,getParentDn,getRdn,isChildOf,isDnInBranch,launchHooks,launchHooksChained,normalizeDn,parseDn,transformSchemas,unescapeDnValue,validateDnValue};
@@ -1 +1 @@
1
- import{AsyncLocalStorage as t}from"async_hooks";import e from"../../lib/auth/base.js";import{ForbiddenError as i}from"../../lib/errors.js";import{unauthorized as n,ok as r}from"../../lib/expressFormatedResponses.js";import{getParentDn as s,asyncHandler as a}from"../../lib/utils.js";import{verifyLdapPassword as o}from"./authzDynamicHash.js";const h=new t;function asString(t){if(null!=t){if(Array.isArray(t)){const e=t[0];return null==e?void 0:Buffer.isBuffer(e)?e.toString():String(e)}return Buffer.isBuffer(t)?t.toString():String(t)}}function isAtOrUnder(t,e){const i=t.toLowerCase().trim(),n=e.toLowerCase().trim();return!(!i||!n)&&(i===n||i.endsWith(","+n))}class AuthzDynamic extends e{constructor(t){if(super(t),this.name="authzDynamic",this.roles=["auth","authz","configurable"],this.tokens=[],this.lastLoad=0,this.lastFailure=0,this.hooks={ldapsearchrequest:([t,e,i])=>{const n=this.activeToken(i);return n?(this.requireBranchPermission(n,t,"read"),[t,e,i]):[t,e,i]},ldapaddrequest:([t,e,i])=>{const n=this.activeToken(i);return n?(this.requireBranchPermission(n,s(t),"write"),[t,e,i]):[t,e,i]},ldapmodifyrequest:([t,e,i,n])=>{const r=this.activeToken(n);return r?(this.requireBranchPermission(r,s(t),"write"),[t,e,i,n]):[t,e,i,n]},ldapdeleterequest:t=>{const e=h.getStore();if(!e)return t;const i=Array.isArray(t)?t:[t];for(const t of i)this.requireBranchPermission(e,s(t),"delete");return t},ldaprenamerequest:([t,e,i])=>{const n=this.activeToken(i);return n?(this.requireBranchPermission(n,s(t),"read"),this.requireBranchPermission(n,s(e),"write"),[t,e,i]):[t,e,i]}},this.base=this.config.authz_dynamic_base||"",this.cacheTtlMs=1e3*(this.config.authz_dynamic_cache_ttl||60),this.failureBackoffMs=Math.max(Math.floor(this.cacheTtlMs/4),5e3),this.tokenAttr=this.config.authz_dynamic_token_attribute||"userPassword",this.configAttr=this.config.authz_dynamic_config_attribute||"description",this.tenantAttr=this.config.authz_dynamic_tenant_attribute||"cn",this.reloadEndpoint=Boolean(this.config.authz_dynamic_reload_endpoint),!this.base)throw new Error("authzDynamic requires --authz-dynamic-base (the LDAP branch holding token entries)")}api(t){if(super.api(t),this.reloadEndpoint){const e=this.config.api_prefix||"/api";t.post(`${e}/v1/authz-dynamic/reload`,a(async(t,e)=>{t.authzToken?(await this.reload(),r(e,{success:!0,tokens:this.tokens.length})):n(e)})),this.logger.info(`authzDynamic reload endpoint registered at ${e}/v1/authz-dynamic/reload`)}}async reload(){try{const t=Array.from(new Set(["dn","cn",this.tokenAttr,this.configAttr,this.tenantAttr])),e=await this.server.ldap.search({filter:"(objectClass=*)",scope:"sub",paged:!1,attributes:t},this.base),i=[];for(const t of e.searchEntries||[]){const e="string"==typeof t.dn?t.dn:String(t.dn),n=asString(t.cn)||"",r=asString(t[this.tokenAttr])||"";if(!r)continue;let s,a=[];const o=asString(t[this.configAttr]);if(o)try{const t=JSON.parse(o);t&&"string"==typeof t.tenant&&t.tenant&&(s=t.tenant),t&&Array.isArray(t.bases)&&(a=t.bases.filter(t=>t&&"string"==typeof t.dn&&t.dn.length>0).map(t=>({dn:t.dn,read:Boolean(t.read),write:Boolean(t.write),delete:Boolean(t.delete)})))}catch(t){this.logger.warn(`authzDynamic: invalid JSON in ${this.configAttr} of ${e}: ${String(t)}`)}const h="cn"===this.tenantAttr?n:asString(t[this.tenantAttr])||n,c=s||h;i.push({dn:e,cn:n,tenant:c,hash:r,bases:a})}this.tokens=i,this.lastLoad=Date.now(),this.lastFailure=0,this.logger.info(`authzDynamic: loaded ${i.length} token(s) from ${this.base}`)}catch(t){this.lastFailure=Date.now(),this.logger.error(`authzDynamic: failed to load tokens from ${this.base}: ${String(t)}`)}}async ensureFresh(){const t=Date.now();if(!(this.lastLoad>0&&t-this.lastLoad<this.cacheTtlMs||this.lastFailure>0&&t-this.lastFailure<this.failureBackoffMs))return this.loading||(this.loading=this.reload().finally(()=>{this.loading=void 0})),this.loading}authMethod(t,e,i){t.user?i():this.ensureFresh().then(()=>{const r=t.headers.authorization;if(!r||!/^Bearer\s+/.test(r))return this.logger.warn("authzDynamic: missing or invalid Authorization header"),void n(e);const s=r.slice(r.indexOf(" ")+1).trim();if(!s)return void n(e);let a;for(const t of this.tokens)if(o(s,t.hash)){a=t;break}if(!a){const t=s.length>8?`${s.substring(0,8)}...`:"***";return this.logger.warn(`authzDynamic: unauthorized token ${t}`),void n(e)}t.user=a.tenant,t.authzToken=a,h.run(a,i)}).catch(t=>{this.logger.error(`authzDynamic: authMethod error: ${String(t)}`),n(e)})}activeToken(t){return h.getStore()||t?.authzToken}requireBranchPermission(t,e,n){for(const i of t.bases)if(isAtOrUnder(e,i.dn)&&i[n])return;throw this.logger.debug(`authzDynamic: token "${t.tenant}" denied ${n} on ${e}`),new i(`[authz-forbidden] Token does not have ${n} permission on this branch`)}getConfigApiData(){return{enabled:!0,base:this.base,cacheTtlSeconds:this.cacheTtlMs/1e3,tokenCount:this.tokens.length,reloadEndpoint:this.reloadEndpoint?`${this.config.api_prefix||"/api"}/v1/authz-dynamic/reload`:void 0,tokenAttribute:this.tokenAttr,configAttribute:this.configAttr,tenantAttribute:this.tenantAttr}}_tokens(){return this.tokens}}export{h as authzContext,AuthzDynamic as default};
1
+ import{AsyncLocalStorage as t}from"async_hooks";import e from"../../lib/auth/base.js";import{ForbiddenError as i}from"../../lib/errors.js";import{unauthorized as n,ok as r}from"../../lib/expressFormatedResponses.js";import{getParentDn as s,asyncHandler as a}from"../../lib/utils.js";import{verifyLdapPassword as o}from"./authzDynamicHash.js";const h=new t;function asString(t){if(null!=t){if(Array.isArray(t)){const e=t[0];return null==e?void 0:Buffer.isBuffer(e)?e.toString():String(e)}return Buffer.isBuffer(t)?t.toString():String(t)}}function isAtOrUnder(t,e){const i=t.toLowerCase().trim(),n=e.toLowerCase().trim();return!(!i||!n)&&(i===n||i.endsWith(","+n))}class AuthzDynamic extends e{constructor(t){if(super(t),this.name="authzDynamic",this.roles=["auth","authz","configurable"],this.tokens=[],this.lastLoad=0,this.lastFailure=0,this.hooks={ldapsearchrequest:([t,e,i])=>{const n=this.activeToken(i);return n?(this.requireBranchPermission(n,t,"read"),[t,e,i]):[t,e,i]},ldapaddrequest:([t,e,i])=>{const n=this.activeToken(i);return n?(this.requireBranchPermission(n,s(t),"write"),[t,e,i]):[t,e,i]},ldapmodifyrequest:([t,e,i,n])=>{const r=this.activeToken(n);return r?(this.requireBranchPermission(r,s(t),"write"),[t,e,i,n]):[t,e,i,n]},ldapdeleterequest:([t,e])=>{const i=this.activeToken(e);if(!i)return[t,e];const n=Array.isArray(t)?t:[t];for(const t of n)this.requireBranchPermission(i,s(t),"delete");return[t,e]},ldaprenamerequest:([t,e,i])=>{const n=this.activeToken(i);return n?(this.requireBranchPermission(n,s(t),"read"),this.requireBranchPermission(n,s(e),"write"),[t,e,i]):[t,e,i]}},this.base=this.config.authz_dynamic_base||"",this.cacheTtlMs=1e3*(this.config.authz_dynamic_cache_ttl||60),this.failureBackoffMs=Math.max(Math.floor(this.cacheTtlMs/4),5e3),this.tokenAttr=this.config.authz_dynamic_token_attribute||"userPassword",this.configAttr=this.config.authz_dynamic_config_attribute||"description",this.tenantAttr=this.config.authz_dynamic_tenant_attribute||"cn",this.reloadEndpoint=Boolean(this.config.authz_dynamic_reload_endpoint),!this.base)throw new Error("authzDynamic requires --authz-dynamic-base (the LDAP branch holding token entries)")}api(t){if(super.api(t),this.reloadEndpoint){const e=this.config.api_prefix||"/api";t.post(`${e}/v1/authz-dynamic/reload`,a(async(t,e)=>{t.authzToken?(await this.reload(),r(e,{success:!0,tokens:this.tokens.length})):n(e)})),this.logger.info(`authzDynamic reload endpoint registered at ${e}/v1/authz-dynamic/reload`)}}async reload(){try{const t=Array.from(new Set(["dn","cn",this.tokenAttr,this.configAttr,this.tenantAttr])),e=await this.server.ldap.search({filter:"(objectClass=*)",scope:"sub",paged:!1,attributes:t},this.base),i=[];for(const t of e.searchEntries||[]){const e="string"==typeof t.dn?t.dn:String(t.dn),n=asString(t.cn)||"",r=asString(t[this.tokenAttr])||"";if(!r)continue;let s,a=[];const o=asString(t[this.configAttr]);if(o)try{const t=JSON.parse(o);t&&"string"==typeof t.tenant&&t.tenant&&(s=t.tenant),t&&Array.isArray(t.bases)&&(a=t.bases.filter(t=>t&&"string"==typeof t.dn&&t.dn.length>0).map(t=>({dn:t.dn,read:Boolean(t.read),write:Boolean(t.write),delete:Boolean(t.delete)})))}catch(t){this.logger.warn(`authzDynamic: invalid JSON in ${this.configAttr} of ${e}: ${String(t)}`)}const h="cn"===this.tenantAttr?n:asString(t[this.tenantAttr])||n,c=s||h;i.push({dn:e,cn:n,tenant:c,hash:r,bases:a})}this.tokens=i,this.lastLoad=Date.now(),this.lastFailure=0,this.logger.info(`authzDynamic: loaded ${i.length} token(s) from ${this.base}`)}catch(t){this.lastFailure=Date.now(),this.logger.error(`authzDynamic: failed to load tokens from ${this.base}: ${String(t)}`)}}async ensureFresh(){const t=Date.now();if(!(this.lastLoad>0&&t-this.lastLoad<this.cacheTtlMs||this.lastFailure>0&&t-this.lastFailure<this.failureBackoffMs))return this.loading||(this.loading=this.reload().finally(()=>{this.loading=void 0})),this.loading}authMethod(t,e,i){t.user?i():this.ensureFresh().then(()=>{const r=t.headers.authorization;if(!r||!/^Bearer\s+/.test(r))return this.logger.warn("authzDynamic: missing or invalid Authorization header"),void n(e);const s=r.slice(r.indexOf(" ")+1).trim();if(!s)return void n(e);let a;for(const t of this.tokens)if(o(s,t.hash)){a=t;break}if(!a){const t=s.length>8?`${s.substring(0,8)}...`:"***";return this.logger.warn(`authzDynamic: unauthorized token ${t}`),void n(e)}t.user=a.tenant,t.authzToken=a,h.run(a,i)}).catch(t=>{this.logger.error(`authzDynamic: authMethod error: ${String(t)}`),n(e)})}activeToken(t){return h.getStore()||t?.authzToken}requireBranchPermission(t,e,n){for(const i of t.bases)if(isAtOrUnder(e,i.dn)&&i[n])return;throw this.logger.debug(`authzDynamic: token "${t.tenant}" denied ${n} on ${e}`),new i(`[authz-forbidden] Token does not have ${n} permission on this branch`)}getConfigApiData(){return{enabled:!0,base:this.base,cacheTtlSeconds:this.cacheTtlMs/1e3,tokenCount:this.tokens.length,reloadEndpoint:this.reloadEndpoint?`${this.config.api_prefix||"/api"}/v1/authz-dynamic/reload`:void 0,tokenAttribute:this.tokenAttr,configAttribute:this.configAttr,tenantAttribute:this.tenantAttr}}_tokens(){return this.tokens}}export{h as authzContext,AuthzDynamic as default};
@@ -1 +1 @@
1
- import e from"fs";import t from"../../abstract/plugin.js";import{wantJson as r,jsonBody as i,tryMethod as s,ok as a}from"../../lib/expressFormatedResponses.js";import{transformSchemas as o,asyncHandler as n,escapeDnValue as d,validateDnValue as h,launchHooksChained as c,launchHooks as p,getCompiledRegex as l,escapeRegex as m,escapeLdapFilter as u}from"../../lib/utils.js";import{NotFoundError as f,BadRequestError as g}from"../../lib/errors.js";class LdapGroups extends t{constructor(t){if(super(t),this.name="ldapGroups",this.roles=["api","configurable"],this.hooks={ldapdeleterequest:async e=>{let t=e;return Array.isArray(t)||(t=[t]),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)}),e}},this.ldap=t.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");this.config.group_schema&&e.readFile(this.config.group_schema,(e,t)=>{if(e)this.logger.error(`Failed to load group schema from ${this.config.group_schema}: ${e}`);else try{this.schema=JSON.parse(o(t.toString(),this.config)),this.logger.debug("Group schema loaded")}catch(e){this.logger.error(`Failed to parse ${this.config.schemas_path}/group.json: ${e}`)}})}api(e){e.get(`${this.config.api_prefix}/v1/ldap/groups`,n(async(e,t)=>{if(!r(e,t))return;const i={};if(e.query.match){if("string"!=typeof e.query.match)throw new g("Invalid match query");if(/=/.test(e.query.match)){if(!/^[\w*=()&|, -]+$/.test(e.query.match))throw new g("Invalid match query: contains forbidden characters");i.filter=e.query.match}else{const t=u(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);return a(t,s)})),e.get(`${this.config.api_prefix}/v1/ldap/groups/:cn`,n(async(e,t)=>this.apiGet(e,t))),e.post(`${this.config.api_prefix}/v1/ldap/groups`,n(async(e,t)=>this.apiAdd(e,t,this.cn))),e.delete(`${this.config.api_prefix}/v1/ldap/groups/:cn`,n(async(e,t)=>this.apiDelete(e,t))),e.put(`${this.config.api_prefix}/v1/ldap/groups/:cn`,n(async(e,t)=>this.apiModify(e,t))),e.post(`${this.config.api_prefix}/v1/ldap/groups/:cn/members`,n(async(e,t)=>{const r=decodeURIComponent(e.params.cn);if(!r)throw new g("cn is required");const a=i(e,t,"member");a&&await s(t,this.addMember.bind(this),r,a.member)})),e.delete(`${this.config.api_prefix}/v1/ldap/groups/:cn/members/:member`,n(async(e,t)=>{if(!r(e,t))return;const i=decodeURIComponent(e.params.cn),a=decodeURIComponent(e.params.member);if(!i||!a)throw new g("cn and member are required");await s(t,this.deleteMember.bind(this),i,a)})),this.config.ldap_organization_link_attribute&&this.config.ldap_organization_path_attribute&&e.post(`${this.config.api_prefix}/v1/ldap/groups/:cn/move`,n(async(e,t)=>{if(!r(e,t))return;const a=decodeURIComponent(e.params.cn);if(!a)throw new g("cn is required");const o=i(e,t,"targetOrgDn");o&&await s(t,this.moveGroup.bind(this),a,o.targetOrgDn,e)})),e.post(`${this.config.api_prefix}/v1/ldap/groups/:cn/rename`,n(async(e,t)=>{if(!r(e,t))return;const a=decodeURIComponent(e.params.cn);if(!a)throw new g("cn is required");const o=i(e,t,"newCn");o&&await s(t,this.renameGroup.bind(this),a,o.newCn)}))}async apiGet(e,t){if(!r(e,t))return;const i=decodeURIComponent(e.params.cn);try{const e=/,/.test(i)?i:`${this.cn}=${d(i)},${this.base}`,r=await this.ldap.search({paged:!1,scope:"base"},e);if(0===r.searchEntries.length)throw new f("Group not found");t.json(r.searchEntries[0])}catch(e){if(e.code&&32===e.code)throw new f("Group not found");throw e}}async apiAdd(e,t,...r){const a=i(e,t,...r);if(!a)return;const o=a[this.cn],n=a.member?a.member:[],d=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,d)}async apiDelete(e,t){if(!r(e,t))return;const i=decodeURIComponent(e.params.cn);if(!i)throw new g("cn is required");await s(t,this.deleteGroup.bind(this),i)}async apiModify(e,t){const r=i(e,t);if(!r)return;const a=this.fixDn(decodeURIComponent(e.params.cn));if(!a)throw new g("cn is required");const o=Object.fromEntries(Object.entries(r).filter(([e,t])=>!(!0===this.schema?.attributes[e]?.fixed)));await s(t,this.modifyGroup.bind(this),a,o)}async addGroup(e,t=[],r={}){let i;new RegExp(`^${this.cn}=`).test(e)?(i=e,e=e.replace(new RegExp(`^${this.cn}=((?:\\\\.|[^,])+)(?:,.*)?$`),"$1")):(h(e,this.cn),i=`${this.cn}=${d(e)},${this.base}`),await this.validateMembers(i,t),await this.validateNewGroup(i,{objectClass:this.config.group_class,cn:e,member:t,...r});const s=[...this.config.group_class];!(r.mail||r.twakeMailboxType||r.twakeDepartmentLink||r.twakeDepartmentPath)||s.includes("twakeGroup")||s.includes("twakeStaticGroup")||s.push("twakeGroup");let a,o={objectClass:s,...this.config.group_default_attributes,[this.cn]:e,member:[this.config.group_dummy_user],...r};t.length&&o.member.push(...t),[i,o]=await c(this.registeredHooks.ldapgroupadd,[i,o]);try{a=await this.ldap.add(i,o)}catch(e){throw new Error(`Failed to add group ${i}: ${e}`)}return p(this.registeredHooks.ldapgroupadddone,[i,o]),a}async modifyGroup(e,t){let r=/,/.test(e)?e:`${this.cn}=${d(e)},${this.base}`;const i=this.opNumber();if([r,t]=await c(this.registeredHooks.ldapgroupmodify,[r,t,i]),t.add){if(t.add.member)throw new Error("Use dedicated API to add members");if(t.add[this.cn])throw new Error(`${this.cn} attribute iq unique, cannot add`)}if(t.delete){if(t.delete instanceof Object){if(t.delete.member)throw new Error("Use dedicated API to delete members");if(t.delete[this.cn])throw new Error(`Cannot delete ${this.cn} attribute`)}if(Array.isArray(t.delete)){if(t.delete.includes("member"))throw new Error("Use dedicated API to delete members");if(t.delete.includes(this.cn))throw new Error(`Cannot delete ${this.cn} attribute`)}}if(t.replace){if(t.replace.member)throw new Error("Use dedicated API to replace members");if(t.replace[this.cn])throw new Error(`Use dedicated API to change ${this.cn} attribute`)}await this.validateChanges(r,t);const s=await this.ldap.modify(r,t);return p(this.registeredHooks.ldapgroupmodifydone,[r,t,i]),s}async renameGroup(e,t){/,/.test(e)||h(e,this.cn),/,/.test(t)||h(t,this.cn);let r=/,/.test(e)?e:`${this.cn}=${d(e)},${this.base}`,i=/,/.test(t)?t:`${this.cn}=${d(t)},${this.base}`;[r,i]=await c(this.registeredHooks.ldapgrouprename,[r,i]);const s=await this.ldap.rename(r,i);return p(this.registeredHooks.ldapgrouprenamedone,[r,i]),s}async deleteGroup(e){let t=/,/.test(e)?e:`${this.cn}=${d(e)},${this.base}`;t=await c(this.registeredHooks.ldapgroupdelete,t);const r=await this.ldap.delete(t);return p(this.registeredHooks.ldapgroupdeletedone,t),r}async addMember(e,t){const r=/,/.test(e)?e:`${this.cn}=${d(e)},${this.base}`;return Array.isArray(t)||(t=[t]),[e,t]=await c(this.registeredHooks.ldapgroupaddmember,[e,t]),await this.validateMembers(r,t),await this.ldap.modify(r,{add:{member:t}}).catch(e=>{throw new Error(`Failed to add member(s) to ${r}: ${e}`)})}async deleteMember(e,t){const r=/,/.test(e)?e:`${this.cn}=${d(e)},${this.base}`;if([e,t]=await c(this.registeredHooks.ldapgroupdeletemember,[e,t]),t===this.config.group_dummy_user)throw new Error("Cannot delete dummy member from group");return await this.ldap.modify(r,{delete:{member:t}}).catch(e=>{throw new Error(`Failed to delete member ${t} from ${r}: ${e}`)})}async deleteMemberFromAll(e){const t=await this.ldap.search({filter:`member=${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=>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}=${d(e)},${this.base}`,o=await this.ldap.search({paged:!1,scope:"base"},a);if(0===o.searchEntries.length)throw new Error(`Group ${a} not found`);const n=o.searchEntries[0][i];if(!n)throw new Error(`Group ${a} does not have ${i} attribute and cannot be moved`);if(n===t)throw new Error("Group is already in the target organization");let h;try{h=await this.ldap.search({paged:!1,scope:"base",attributes:[s,"ou","o"]},t)}catch(e){throw new Error(`Target organization ${t} not found: ${e instanceof Error&&e.message?e.message:String(e)}`)}if(0===h.searchEntries.length)throw new Error(`Target organization ${t} not found`);const c=h.searchEntries[0][s];if(!c)throw new Error(`Target organization ${t} does not have ${s} attribute`);return await this.ldap.modify(a,{replace:{[i]:t,[s]:c}},r),this.logger.info(`Group ${a} moved from ${n} to ${t}`),{success:!0}}async listGroups({filter:e,attributes:t}={}){const r=await this.ldap.search({filter:e||"(objectClass=*)",attributes:t||[this.cn,"member"],paged:!0},this.base).catch(e=>{throw new Error(`Failed to list groups from ${this.base}: ${e}`)});let i={};for await(const e of r)e.searchEntries.map(e=>{const t=e[this.cn];t&&(i[t]=e),Array.isArray(i[t].member)||(i[t].member=[i[t].member]),i[t].member=i[t].member.filter(e=>e!==this.config.group_dummy_user)});return i=await c(this.registeredHooks._ldapgrouplist,i),i}async searchGroupsByName(e,t=!1,r=[this.cn,"member"]){const i=t?`(${this.cn}=*${e}*)`:`(${this.cn}=${e})`;return await this.listGroups({filter:i,attributes:r})}fixDn(e){return!!e&&(/,/.test(e)?e:`${this.cn}=${d(e)},${this.base}`)}async validateMembers(e,t){if([e,t]=await c(this.server.hooks.ldapgroupvalidatemembers,[e,t]),!this.config.groups_allow_unexistent_members&&t&&t.length)try{await Promise.all(t.map(e=>this.server.ldap.queryLimit(async()=>{try{await this.ldap.search({paged:!1,scope:"base"},e)}catch(t){throw new Error(`Member ${e} not found: ${t}`)}})))}catch(e){throw new Error(`Failed to find member(s): ${e}`)}}async validateNewGroup(e,t){if(!this.schema)return!0;[e,t]=await c(this.server.hooks.ldapgroupvalidatenew,[e,t]);for(const[e,r]of Object.entries(t))if(!await this._validateOneChange(e,r))throw new Error(`Invalid value for field ${e}`);for(const[e,r]of Object.entries(this.schema.attributes))if(r.required&&null==t[e])throw new Error(`Missing required field ${e}`);return!0}async validateChanges(e,t){if(!this.schema)return!0;if([e,t]=await c(this.server.hooks.ldapgroupvalidatechanges,[e,t]),t.add)for(const[e,r]of Object.entries(t.add))await this._validateOneChange(e,r);if(t.replace)for(const[e,r]of Object.entries(t.replace))await this._validateOneChange(e,r);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!0}async _validateOneChange(e,t){if(!this.schema)return!0;const r=this.schema.attributes[e];if(!r){if(this.schema.strict)throw new Error(`Field ${e} is not allowed`);return!0}if(null==t){if(r.required)throw new Error(`Field ${e} is required`);return!0}if("array"===r.type){if(!Array.isArray(t))throw new Error(`Field ${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){const i="string"==typeof r.items.test?l(r.items.test):r.items.test;for(let s of t){if(typeof s!==r.items.type)throw new Error(`Field ${e} must be of type ${r.items.type}`);if("string"!=typeof s&&(s=s.toString()),!i.test(s))throw new Error(`Field ${e} has invalid value ${s}`)}}}else if("pointer"===r.type){if("string"!=typeof t)throw new Error(`Field ${e} must be a string (DN pointer)`);const i=t;if(r.branch&&r.branch.length>0){if(!r.branch.some(e=>l(`,?${m(e)}$`,"i").test(i)))throw new Error(`Field ${e} must point to a DN within allowed branches: ${r.branch.join(", ")}`)}try{const t=await this.ldap.search({paged:!1,scope:"base"},i);if(!t||!t.searchEntries||0===t.searchEntries.length)throw new Error(`Field ${e} points to non-existent DN: ${i}`)}catch(t){throw new Error(`Field ${e} points to invalid or non-existent DN: ${i}: ${t}`)}if(r.test){if(!("string"==typeof r.test?l(r.test):r.test).test(i))throw new Error(`Field ${e} has invalid value ${i}`)}}else{if(typeof t!==r.type)return!1;if("string"!=typeof t&&(t=t.toString()),r.test){if(!("string"==typeof r.test?l(r.test):r.test).test(t))throw new Error(`Field ${e} has invalid value ${t}`)}}return!0}getConfigApiData(){const e=this.config.api_prefix||"/api";let t;if(this.server.loadedPlugins.static&&this.config.group_schema){const e=this.config.static_name||"static",r=this.config.group_schema.indexOf("/schemas/");if(-1!==r){t=`/${e}${this.config.group_schema.substring(r)}`}}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"fs";import t from"../../abstract/plugin.js";import{wantJson as r,jsonBody as i,tryMethod as s,ok as a}from"../../lib/expressFormatedResponses.js";import{transformSchemas as o,asyncHandler as n,escapeDnValue as d,validateDnValue as h,launchHooksChained as c,launchHooks as p,getCompiledRegex as l,escapeRegex as m,escapeLdapFilter as u}from"../../lib/utils.js";import{NotFoundError as f,BadRequestError as g}from"../../lib/errors.js";class LdapGroups extends t{constructor(t){if(super(t),this.name="ldapGroups",this.roles=["api","configurable"],this.hooks={ldapdeleterequest:async([e,t])=>{let r=e;return Array.isArray(r)||(r=[r]),this.logger.debug(`User deletion detected, removing from groups: ${r.join(", ")}`),await Promise.all(r.map(e=>this.deleteMemberFromAll(e))).catch(e=>{this.logger.error("Failed to process user deletion in groups:",e)}),[e,t]}},this.ldap=t.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");this.config.group_schema&&e.readFile(this.config.group_schema,(e,t)=>{if(e)this.logger.error(`Failed to load group schema from ${this.config.group_schema}: ${e}`);else try{this.schema=JSON.parse(o(t.toString(),this.config)),this.logger.debug("Group schema loaded")}catch(e){this.logger.error(`Failed to parse ${this.config.schemas_path}/group.json: ${e}`)}})}api(e){e.get(`${this.config.api_prefix}/v1/ldap/groups`,n(async(e,t)=>{if(!r(e,t))return;const i={};if(e.query.match){if("string"!=typeof e.query.match)throw new g("Invalid match query");if(/=/.test(e.query.match)){if(!/^[\w*=()&|, -]+$/.test(e.query.match))throw new g("Invalid match query: contains forbidden characters");i.filter=e.query.match}else{const t=u(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);return a(t,s)})),e.get(`${this.config.api_prefix}/v1/ldap/groups/:cn`,n(async(e,t)=>this.apiGet(e,t))),e.post(`${this.config.api_prefix}/v1/ldap/groups`,n(async(e,t)=>this.apiAdd(e,t,this.cn))),e.delete(`${this.config.api_prefix}/v1/ldap/groups/:cn`,n(async(e,t)=>this.apiDelete(e,t))),e.put(`${this.config.api_prefix}/v1/ldap/groups/:cn`,n(async(e,t)=>this.apiModify(e,t))),e.post(`${this.config.api_prefix}/v1/ldap/groups/:cn/members`,n(async(e,t)=>{const r=decodeURIComponent(e.params.cn);if(!r)throw new g("cn is required");const a=i(e,t,"member");a&&await s(t,this.addMember.bind(this),r,a.member)})),e.delete(`${this.config.api_prefix}/v1/ldap/groups/:cn/members/:member`,n(async(e,t)=>{if(!r(e,t))return;const i=decodeURIComponent(e.params.cn),a=decodeURIComponent(e.params.member);if(!i||!a)throw new g("cn and member are required");await s(t,this.deleteMember.bind(this),i,a)})),this.config.ldap_organization_link_attribute&&this.config.ldap_organization_path_attribute&&e.post(`${this.config.api_prefix}/v1/ldap/groups/:cn/move`,n(async(e,t)=>{if(!r(e,t))return;const a=decodeURIComponent(e.params.cn);if(!a)throw new g("cn is required");const o=i(e,t,"targetOrgDn");o&&await s(t,this.moveGroup.bind(this),a,o.targetOrgDn,e)})),e.post(`${this.config.api_prefix}/v1/ldap/groups/:cn/rename`,n(async(e,t)=>{if(!r(e,t))return;const a=decodeURIComponent(e.params.cn);if(!a)throw new g("cn is required");const o=i(e,t,"newCn");o&&await s(t,this.renameGroup.bind(this),a,o.newCn)}))}async apiGet(e,t){if(!r(e,t))return;const i=decodeURIComponent(e.params.cn);try{const e=/,/.test(i)?i:`${this.cn}=${d(i)},${this.base}`,r=await this.ldap.search({paged:!1,scope:"base"},e);if(0===r.searchEntries.length)throw new f("Group not found");t.json(r.searchEntries[0])}catch(e){if(e.code&&32===e.code)throw new f("Group not found");throw e}}async apiAdd(e,t,...r){const a=i(e,t,...r);if(!a)return;const o=a[this.cn],n=a.member?a.member:[],d=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,d)}async apiDelete(e,t){if(!r(e,t))return;const i=decodeURIComponent(e.params.cn);if(!i)throw new g("cn is required");await s(t,this.deleteGroup.bind(this),i)}async apiModify(e,t){const r=i(e,t);if(!r)return;const a=this.fixDn(decodeURIComponent(e.params.cn));if(!a)throw new g("cn is required");const o=Object.fromEntries(Object.entries(r).filter(([e,t])=>!(!0===this.schema?.attributes[e]?.fixed)));await s(t,this.modifyGroup.bind(this),a,o)}async addGroup(e,t=[],r={}){let i;new RegExp(`^${this.cn}=`).test(e)?(i=e,e=e.replace(new RegExp(`^${this.cn}=((?:\\\\.|[^,])+)(?:,.*)?$`),"$1")):(h(e,this.cn),i=`${this.cn}=${d(e)},${this.base}`),await this.validateMembers(i,t),await this.validateNewGroup(i,{objectClass:this.config.group_class,cn:e,member:t,...r});const s=[...this.config.group_class];!(r.mail||r.twakeMailboxType||r.twakeDepartmentLink||r.twakeDepartmentPath)||s.includes("twakeGroup")||s.includes("twakeStaticGroup")||s.push("twakeGroup");let a,o={objectClass:s,...this.config.group_default_attributes,[this.cn]:e,member:[this.config.group_dummy_user],...r};t.length&&o.member.push(...t),[i,o]=await c(this.registeredHooks.ldapgroupadd,[i,o]);try{a=await this.ldap.add(i,o)}catch(e){throw new Error(`Failed to add group ${i}: ${e}`)}return p(this.registeredHooks.ldapgroupadddone,[i,o]),a}async modifyGroup(e,t){let r=/,/.test(e)?e:`${this.cn}=${d(e)},${this.base}`;const i=this.opNumber();if([r,t]=await c(this.registeredHooks.ldapgroupmodify,[r,t,i]),t.add){if(t.add.member)throw new Error("Use dedicated API to add members");if(t.add[this.cn])throw new Error(`${this.cn} attribute iq unique, cannot add`)}if(t.delete){if(t.delete instanceof Object){if(t.delete.member)throw new Error("Use dedicated API to delete members");if(t.delete[this.cn])throw new Error(`Cannot delete ${this.cn} attribute`)}if(Array.isArray(t.delete)){if(t.delete.includes("member"))throw new Error("Use dedicated API to delete members");if(t.delete.includes(this.cn))throw new Error(`Cannot delete ${this.cn} attribute`)}}if(t.replace){if(t.replace.member)throw new Error("Use dedicated API to replace members");if(t.replace[this.cn])throw new Error(`Use dedicated API to change ${this.cn} attribute`)}await this.validateChanges(r,t);const s=await this.ldap.modify(r,t);return p(this.registeredHooks.ldapgroupmodifydone,[r,t,i]),s}async renameGroup(e,t){/,/.test(e)||h(e,this.cn),/,/.test(t)||h(t,this.cn);let r=/,/.test(e)?e:`${this.cn}=${d(e)},${this.base}`,i=/,/.test(t)?t:`${this.cn}=${d(t)},${this.base}`;[r,i]=await c(this.registeredHooks.ldapgrouprename,[r,i]);const s=await this.ldap.rename(r,i);return p(this.registeredHooks.ldapgrouprenamedone,[r,i]),s}async deleteGroup(e){let t=/,/.test(e)?e:`${this.cn}=${d(e)},${this.base}`;t=await c(this.registeredHooks.ldapgroupdelete,t);const r=await this.ldap.delete(t);return p(this.registeredHooks.ldapgroupdeletedone,t),r}async addMember(e,t){const r=/,/.test(e)?e:`${this.cn}=${d(e)},${this.base}`;return Array.isArray(t)||(t=[t]),[e,t]=await c(this.registeredHooks.ldapgroupaddmember,[e,t]),await this.validateMembers(r,t),await this.ldap.modify(r,{add:{member:t}}).catch(e=>{throw new Error(`Failed to add member(s) to ${r}: ${e}`)})}async deleteMember(e,t){const r=/,/.test(e)?e:`${this.cn}=${d(e)},${this.base}`;if([e,t]=await c(this.registeredHooks.ldapgroupdeletemember,[e,t]),t===this.config.group_dummy_user)throw new Error("Cannot delete dummy member from group");return await this.ldap.modify(r,{delete:{member:t}}).catch(e=>{throw new Error(`Failed to delete member ${t} from ${r}: ${e}`)})}async deleteMemberFromAll(e){const t=await this.ldap.search({filter:`member=${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=>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}=${d(e)},${this.base}`,o=await this.ldap.search({paged:!1,scope:"base"},a);if(0===o.searchEntries.length)throw new Error(`Group ${a} not found`);const n=o.searchEntries[0][i];if(!n)throw new Error(`Group ${a} does not have ${i} attribute and cannot be moved`);if(n===t)throw new Error("Group is already in the target organization");let h;try{h=await this.ldap.search({paged:!1,scope:"base",attributes:[s,"ou","o"]},t)}catch(e){throw new Error(`Target organization ${t} not found: ${e instanceof Error&&e.message?e.message:String(e)}`)}if(0===h.searchEntries.length)throw new Error(`Target organization ${t} not found`);const c=h.searchEntries[0][s];if(!c)throw new Error(`Target organization ${t} does not have ${s} attribute`);return await this.ldap.modify(a,{replace:{[i]:t,[s]:c}},r),this.logger.info(`Group ${a} moved from ${n} to ${t}`),{success:!0}}async listGroups({filter:e,attributes:t}={}){const r=await this.ldap.search({filter:e||"(objectClass=*)",attributes:t||[this.cn,"member"],paged:!0},this.base).catch(e=>{throw new Error(`Failed to list groups from ${this.base}: ${e}`)});let i={};for await(const e of r)e.searchEntries.map(e=>{const t=e[this.cn];t&&(i[t]=e),Array.isArray(i[t].member)||(i[t].member=[i[t].member]),i[t].member=i[t].member.filter(e=>e!==this.config.group_dummy_user)});return i=await c(this.registeredHooks._ldapgrouplist,i),i}async searchGroupsByName(e,t=!1,r=[this.cn,"member"]){const i=t?`(${this.cn}=*${e}*)`:`(${this.cn}=${e})`;return await this.listGroups({filter:i,attributes:r})}fixDn(e){return!!e&&(/,/.test(e)?e:`${this.cn}=${d(e)},${this.base}`)}async validateMembers(e,t){if([e,t]=await c(this.server.hooks.ldapgroupvalidatemembers,[e,t]),!this.config.groups_allow_unexistent_members&&t&&t.length)try{await Promise.all(t.map(e=>this.server.ldap.queryLimit(async()=>{try{await this.ldap.search({paged:!1,scope:"base"},e)}catch(t){throw new Error(`Member ${e} not found: ${t}`)}})))}catch(e){throw new Error(`Failed to find member(s): ${e}`)}}async validateNewGroup(e,t){if(!this.schema)return!0;[e,t]=await c(this.server.hooks.ldapgroupvalidatenew,[e,t]);for(const[e,r]of Object.entries(t))if(!await this._validateOneChange(e,r))throw new Error(`Invalid value for field ${e}`);for(const[e,r]of Object.entries(this.schema.attributes))if(r.required&&null==t[e])throw new Error(`Missing required field ${e}`);return!0}async validateChanges(e,t){if(!this.schema)return!0;if([e,t]=await c(this.server.hooks.ldapgroupvalidatechanges,[e,t]),t.add)for(const[e,r]of Object.entries(t.add))await this._validateOneChange(e,r);if(t.replace)for(const[e,r]of Object.entries(t.replace))await this._validateOneChange(e,r);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!0}async _validateOneChange(e,t){if(!this.schema)return!0;const r=this.schema.attributes[e];if(!r){if(this.schema.strict)throw new Error(`Field ${e} is not allowed`);return!0}if(null==t){if(r.required)throw new Error(`Field ${e} is required`);return!0}if("array"===r.type){if(!Array.isArray(t))throw new Error(`Field ${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){const i="string"==typeof r.items.test?l(r.items.test):r.items.test;for(let s of t){if(typeof s!==r.items.type)throw new Error(`Field ${e} must be of type ${r.items.type}`);if("string"!=typeof s&&(s=s.toString()),!i.test(s))throw new Error(`Field ${e} has invalid value ${s}`)}}}else if("pointer"===r.type){if("string"!=typeof t)throw new Error(`Field ${e} must be a string (DN pointer)`);const i=t;if(r.branch&&r.branch.length>0){if(!r.branch.some(e=>l(`,?${m(e)}$`,"i").test(i)))throw new Error(`Field ${e} must point to a DN within allowed branches: ${r.branch.join(", ")}`)}try{const t=await this.ldap.search({paged:!1,scope:"base"},i);if(!t||!t.searchEntries||0===t.searchEntries.length)throw new Error(`Field ${e} points to non-existent DN: ${i}`)}catch(t){throw new Error(`Field ${e} points to invalid or non-existent DN: ${i}: ${t}`)}if(r.test){if(!("string"==typeof r.test?l(r.test):r.test).test(i))throw new Error(`Field ${e} has invalid value ${i}`)}}else{if(typeof t!==r.type)return!1;if("string"!=typeof t&&(t=t.toString()),r.test){if(!("string"==typeof r.test?l(r.test):r.test).test(t))throw new Error(`Field ${e} has invalid value ${t}`)}}return!0}getConfigApiData(){const e=this.config.api_prefix||"/api";let t;if(this.server.loadedPlugins.static&&this.config.group_schema){const e=this.config.static_name||"static",r=this.config.group_schema.indexOf("/schemas/");if(-1!==r){t=`/${e}${this.config.group_schema.substring(r)}`}}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 t from"../../abstract/plugin.js";import{launchHooks as e}from"../../lib/utils.js";const n={mail_attribute:"onLdapMailChange",quota_attribute:"onLdapQuotaChange",alias_attribute:"onLdapAliasChange",forward_attribute:"onLdapForwardChange",display_name_attribute:"onLdapDisplayNameChange",drive_quota_attribute:"onLdapDriveQuotaChange"};class OnLdapChange extends t{constructor(){super(...arguments),this.name="onLdapChange",this.roles=["consistency"],this.stack={},this.pendingDeletions=new Map,this.hooks={ldapadddone:t=>{const[e,n]=t,r={};for(const[t,e]of Object.entries(n))r[t]=[null,e];this.notify(e,r)},ldapmodifyrequest:async([t,e,n])=>{const r=await this.server.ldap.search({paged:!1},t);return 1==r.searchEntries.length?this.stack[n]=r.searchEntries[0]:this.logger.warn(`Could not find unique entry ${t} before modification, got ${r.searchEntries.length} entries`),[t,e,n]},ldapmodifydone:([t,e,n])=>{const r=this.stack[n];if(!r)return delete this.stack[n],void this.logger.warn(`Received a ldapmodifydone for an unknown operation (${n})`);const i={};if(e.add)for(const[t,n]of Object.entries(e.add))i[t]=[null,n];if(e.delete)if(Array.isArray(e.delete))for(const t of e.delete)i[t]=[r[t],null];else for(const[t,n]of Object.entries(e.delete))i[t]=[n,null];if(e.replace)for(const[t,n]of Object.entries(e.replace))i[t]=[r[t],n];this.notify(t,i)},ldapdeleterequest:async t=>{const e=Array.isArray(t)?t:[t];for(const t of e)try{const e=await this.server.ldap.search({scope:"base",paged:!1},t),n=e.searchEntries?.[0];n&&this.pendingDeletions.set(t,n)}catch(t){}return t},ldapdeletedone:t=>{const e=Array.isArray(t)?t:[t];for(const t of e){const e=this.pendingDeletions.get(t);if(e){this.pendingDeletions.delete(t);const n={};for(const[t,r]of Object.entries(e))"dn"!==t&&(n[t]=[r,null]);this.notify(t,n)}}}}}notify(t,r){e(this.server.hooks.onLdapChange,t,r);for(const[i,a]of Object.entries(n))if(this.config[i]&&r[this.config[i]])if("onLdapQuotaChange"===a||"onLdapForwardChange"===a||"onLdapAliasChange"===a)this.notifyAttributeChangeWithMail(this.config[i],a,t,r);else if("onLdapMailChange"===a)this.notifyAttributeChange(this.config[i],a,t,r);else if("onLdapDriveQuotaChange"===a){const[n,a]=r[this.config[i]]||[],s=n?Number(n):null,o=a?Number(a):null;s!==o&&e(this.server.hooks.onLdapDriveQuotaChange,t,s,o)}if(r.cn||r.givenName||r.sn){const n=this.reconstructDisplayName(r,0),i=this.reconstructDisplayName(r,1);e(this.server.hooks.onLdapDisplayNameChange,t,n,i)}}notifyAttributeChange(t,n,r,i,a=!1){const[s,o]=i[t]||[];void 0===s&&void 0===o||(a&&(Array.isArray(s)||Array.isArray(o))?this.logger.error(`Attribute ${t} change detected but one of the values is an array, cannot handle that`):s!==o&&e(this.server.hooks[n],r,s,o))}async notifyAttributeChangeWithMail(t,n,r,i){const[a,s]=i[t]||[];if(void 0===a&&void 0===s)return;const o=this.config.mail_attribute||"mail",h=i[o];let l;if(h)l=Array.isArray(h[1])?String(h[1][0]):String(h[1]);else try{const t=await this.server.ldap.search({paged:!1,scope:"base",attributes:[o]},r);if(1!==t.searchEntries.length)return void this.logger.warn(`Could not find mail for ${r}, skipping ${n} notification`);{const e=t.searchEntries[0][o];l=Array.isArray(e)?String(e[0]):String(e)}}catch(t){return void this.logger.error(`Error fetching mail for ${r}:`,t)}if("onLdapQuotaChange"===n){const t=a?Number(a):0,i=s?Number(s):0;t!==i&&e(this.server.hooks[n],r,l,t,i)}else if("onLdapForwardChange"===n||"onLdapAliasChange"===n){const t=a?Array.isArray(a)?a:[a]:[],i=s?Array.isArray(s)?s:[s]:[];(t.length>0||i.length>0)&&e(this.server.hooks[n],r,l,t,i)}}reconstructDisplayName(t,e){const getValue=n=>{if(!t[n])return null;const r=t[n][e];return r?Array.isArray(r)?r.length>0?String(r[0]):null:String(r):null},n=getValue("cn");if(n)return n;const r=getValue("givenName"),i=getValue("sn");if(r||i){const t=[];return r&&t.push(r),i&&t.push(i),t.join(" ")}return null}}export{OnLdapChange as default};
1
+ import t from"../../abstract/plugin.js";import{launchHooks as e}from"../../lib/utils.js";const n={mail_attribute:"onLdapMailChange",quota_attribute:"onLdapQuotaChange",alias_attribute:"onLdapAliasChange",forward_attribute:"onLdapForwardChange",display_name_attribute:"onLdapDisplayNameChange",drive_quota_attribute:"onLdapDriveQuotaChange"};class OnLdapChange extends t{constructor(){super(...arguments),this.name="onLdapChange",this.roles=["consistency"],this.stack={},this.pendingDeletions=new Map,this.hooks={ldapadddone:t=>{const[e,n]=t,r={};for(const[t,e]of Object.entries(n))r[t]=[null,e];this.notify(e,r)},ldapmodifyrequest:async([t,e,n])=>{const r=await this.server.ldap.search({paged:!1},t);return 1==r.searchEntries.length?this.stack[n]=r.searchEntries[0]:this.logger.warn(`Could not find unique entry ${t} before modification, got ${r.searchEntries.length} entries`),[t,e,n]},ldapmodifydone:([t,e,n])=>{const r=this.stack[n];if(!r)return delete this.stack[n],void this.logger.warn(`Received a ldapmodifydone for an unknown operation (${n})`);const i={};if(e.add)for(const[t,n]of Object.entries(e.add))i[t]=[null,n];if(e.delete)if(Array.isArray(e.delete))for(const t of e.delete)i[t]=[r[t],null];else for(const[t,n]of Object.entries(e.delete))i[t]=[n,null];if(e.replace)for(const[t,n]of Object.entries(e.replace))i[t]=[r[t],n];this.notify(t,i)},ldapdeleterequest:async([t,e])=>{const n=Array.isArray(t)?t:[t];for(const t of n)try{const e=await this.server.ldap.search({scope:"base",paged:!1},t),n=e.searchEntries?.[0];n&&this.pendingDeletions.set(t,n)}catch(t){}return[t,e]},ldapdeletedone:t=>{const e=Array.isArray(t)?t:[t];for(const t of e){const e=this.pendingDeletions.get(t);if(e){this.pendingDeletions.delete(t);const n={};for(const[t,r]of Object.entries(e))"dn"!==t&&(n[t]=[r,null]);this.notify(t,n)}}}}}notify(t,r){e(this.server.hooks.onLdapChange,t,r);for(const[i,a]of Object.entries(n))if(this.config[i]&&r[this.config[i]])if("onLdapQuotaChange"===a||"onLdapForwardChange"===a||"onLdapAliasChange"===a)this.notifyAttributeChangeWithMail(this.config[i],a,t,r);else if("onLdapMailChange"===a)this.notifyAttributeChange(this.config[i],a,t,r);else if("onLdapDriveQuotaChange"===a){const[n,a]=r[this.config[i]]||[],s=n?Number(n):null,o=a?Number(a):null;s!==o&&e(this.server.hooks.onLdapDriveQuotaChange,t,s,o)}if(r.cn||r.givenName||r.sn){const n=this.reconstructDisplayName(r,0),i=this.reconstructDisplayName(r,1);e(this.server.hooks.onLdapDisplayNameChange,t,n,i)}}notifyAttributeChange(t,n,r,i,a=!1){const[s,o]=i[t]||[];void 0===s&&void 0===o||(a&&(Array.isArray(s)||Array.isArray(o))?this.logger.error(`Attribute ${t} change detected but one of the values is an array, cannot handle that`):s!==o&&e(this.server.hooks[n],r,s,o))}async notifyAttributeChangeWithMail(t,n,r,i){const[a,s]=i[t]||[];if(void 0===a&&void 0===s)return;const o=this.config.mail_attribute||"mail",h=i[o];let l;if(h)l=Array.isArray(h[1])?String(h[1][0]):String(h[1]);else try{const t=await this.server.ldap.search({paged:!1,scope:"base",attributes:[o]},r);if(1!==t.searchEntries.length)return void this.logger.warn(`Could not find mail for ${r}, skipping ${n} notification`);{const e=t.searchEntries[0][o];l=Array.isArray(e)?String(e[0]):String(e)}}catch(t){return void this.logger.error(`Error fetching mail for ${r}:`,t)}if("onLdapQuotaChange"===n){const t=a?Number(a):0,i=s?Number(s):0;t!==i&&e(this.server.hooks[n],r,l,t,i)}else if("onLdapForwardChange"===n||"onLdapAliasChange"===n){const t=a?Array.isArray(a)?a:[a]:[],i=s?Array.isArray(s)?s:[s]:[];(t.length>0||i.length>0)&&e(this.server.hooks[n],r,l,t,i)}}reconstructDisplayName(t,e){const getValue=n=>{if(!t[n])return null;const r=t[n][e];return r?Array.isArray(r)?r.length>0?String(r[0]):null:String(r):null},n=getValue("cn");if(n)return n;const r=getValue("givenName"),i=getValue("sn");if(r||i){const t=[];return r&&t.push(r),i&&t.push(i),t.join(" ")}return null}}export{OnLdapChange as default};
@@ -1 +1 @@
1
- import t from"fs";import i from"../../abstract/plugin.js";import{tryMethodData as a,jsonBody as e,tryMethod as n,wantJson as r}from"../../lib/expressFormatedResponses.js";import{transformSchemas as s,asyncHandler as o,validateDnValue as h,escapeDnValue as c,escapeLdapFilter as d,launchHooksChained as g,getParentDn as l,getRdn as p,isChildOf as f}from"../../lib/utils.js";import{BadRequestError as u,NotFoundError as w,ConflictError as $}from"../../lib/errors.js";class LdapOrganizations extends i{constructor(i){if(super(i),this.name="ldapOrganizations",this.roles=["api","consistency","configurable"],this.hooks={ldapaddrequest:async([t,i,a])=>(this.isOu(i)||await this.checkDeptLink(i),this.isOu(i)&&await this.checkDeptPath(i),void 0!==a?[t,i,a]:[t,i]),ldapmodifyrequest:async([t,i,a])=>{let e,n={},r={};const checkIsOu=async()=>{if(void 0!==e)return e;if(i.replace?.objectClass)e=this.isOu({objectClass:i.replace.objectClass});else if(i.add?.objectClass)e=this.isOu({objectClass:i.add.objectClass});else{const i=await this.server.ldap.search({paged:!1,scope:"base"},t);e=i.searchEntries.length>0&&this.isOu(i.searchEntries[0])}return e};if(i.delete){const t=Array.isArray(i.delete)?i.delete.includes(this.linkAttr):i.delete[this.linkAttr],a=Array.isArray(i.delete)?i.delete.includes(this.pathAttr):i.delete[this.pathAttr];if(t||a){if(!await checkIsOu()&&t)throw new u("An organization link cannot be deleted");if(a)throw new u("An organization path cannot be deleted")}}if(i.replace&&(i.replace[this.linkAttr]&&(n={...i.replace}),i.replace[this.pathAttr]&&(r={...i.replace})),i.add&&(i.add[this.linkAttr]&&(n={...n,...i.add}),i.add[this.pathAttr]&&(r={...r,...i.add})),Object.keys(n).length>0){await checkIsOu()||await this.checkDeptLink(n)}if(Object.keys(r).length>0){await checkIsOu()&&await this.checkDeptPath(r)}return[t,i,a]},ldapdeleterequest:async([t])=>(/^ou=/.test(t)&&await this.isEmptyOrganization(t),[t]),ldaprenamerequest:([t,i])=>[t,i]},!this.config.ldap_top_organization)throw new Error("Missing --ldap-top-organization");this.pathAttr=this.config.ldap_organization_path_attribute,this.linkAttr=this.config.ldap_organization_link_attribute,this.config.organization_schema&&t.readFile(this.config.organization_schema,(t,i)=>{if(t)this.logger.error(`Failed to load organization schema from ${this.config.organization_schema}: ${t}`);else try{this.schema=JSON.parse(s(i.toString(),this.config)),this.logger.debug("Organization schema loaded")}catch(t){this.logger.error(`Failed to parse organization schema: ${t}`)}})}api(t){t.get(`${this.config.api_prefix}/v1/ldap/organizations/top`,async(t,i)=>{await a(i,this.getOrganisationTop.bind(this),t)}),t.get(`${this.config.api_prefix}/v1/ldap/organizations/:dn`,async(t,i)=>{const e=decodeURIComponent(t.params.dn);await a(i,this.getOrganisationByDn.bind(this),e,t)}),t.get(`${this.config.api_prefix}/v1/ldap/organizations/:dn/subnodes`,async(t,i)=>{const e=decodeURIComponent(t.params.dn);await a(i,this.getOrganisationSubnodes.bind(this),e,t)}),t.get(`${this.config.api_prefix}/v1/ldap/organizations/:dn/subnodes/search`,o(async(t,i)=>{const e=decodeURIComponent(t.params.dn),n=t.query.q;if(!n)throw new u('query parameter "q" is required');await a(i,this.searchOrganisationSubnodes.bind(this),e,n)})),t.post(`${this.config.api_prefix}/v1/ldap/organizations`,o(async(t,i)=>this.apiAdd(t,i))),t.put(`${this.config.api_prefix}/v1/ldap/organizations/:dn`,o(async(t,i)=>this.apiModify(t,i))),t.delete(`${this.config.api_prefix}/v1/ldap/organizations/:dn`,o(async(t,i)=>this.apiDelete(t,i))),t.post(`${this.config.api_prefix}/v1/ldap/organizations/:dn/move`,o(async(t,i)=>this.apiMove(t,i)))}async apiAdd(t,i){const a=e(t,i,"ou");if(!a)return;h(a.ou,"ou");const r=a.parentDn||this.config.ldap_top_organization,s=`ou=${c(a.ou)},${r}`,o={objectClass:this.config.ldap_organization_class,ou:a.ou,...Object.fromEntries(Object.entries(a).filter(([t])=>"ou"!==t&&"parentDn"!==t))};await n(i,this.addOrganization.bind(this),s,o,t)}async apiModify(t,i){const a=e(t,i);if(!a)return;const r=decodeURIComponent(t.params.dn);if(!r)throw new u("dn is required");await n(i,this.modifyOrganization.bind(this),r,a)}async apiDelete(t,i){if(!r(t,i))return;const a=decodeURIComponent(t.params.dn);if(!a)throw new u("dn is required");await n(i,this.deleteOrganization.bind(this),a)}async apiMove(t,i){if(!r(t,i))return;const n=e(t,i,"targetOrgDn");if(!n)return;const s=decodeURIComponent(t.params.dn);if(!s)throw new u("dn is required");const{targetOrgDn:o}=n;if(!o||"string"!=typeof o)throw new u("Missing or invalid targetOrgDn in request body");await a(i,this.moveOrganization.bind(this),s,o,t)}async checkDeptLink(t){if(t[this.linkAttr]){const i=t[this.linkAttr],a=Array.isArray(i)?i[0]:i,e=await this.server.ldap.search({paged:!1,scope:"base"},a);if(0===e.searchEntries.length)throw new w(`Organization ${a} does not exist`);if(!new RegExp(`(.*,)?${this.config.ldap_top_organization}`).test(e.searchEntries[0].dn))throw new u(`Entry ${a} isn't in top organization branch`)}}async checkDeptPath(t){if(t[this.pathAttr]){const i=t[this.pathAttr],a=Array.isArray(i)?i[0]:i,e=this.config.ldap_organization_path_separator||" / ";let n=a;if(this.isOu(t)){const i=t.ou,r=Array.isArray(i)?i[0]:i;if(!a.startsWith(r+e))throw new u(`Organization path must start with its own name followed by separator "${e}"`);n=a.slice(r.length+e.length)}const[r,s]=n.split(e,2),o=await this.server.ldap.search({paged:!1,filter:`(ou=${d(r)})`,scope:"sub"},this.config.ldap_top_organization);if(0===o.searchEntries.length)throw new u(`Invalid organization path ${a}`);if(s){let t=!1;for(const i of o.searchEntries){const a=i[this.pathAttr],e=Array.isArray(a)?a[0]:a;if(e&&e===s){t=!0;break}}if(!t)throw new u(`Invalid organization path ${a}: no matching entry for ${r} with path ${s}`)}else{let t=!1;for(const i of o.searchEntries){const a=i.dn,n=this.config.ldap_top_organization;if(a.toLowerCase()===n.toLowerCase()||a.toLowerCase().endsWith(`,${n.toLowerCase()}`)){const a=i[this.pathAttr],n=Array.isArray(a)?a[0]:a;if(!n||n===r||!n.includes(e)){t=!0;break}}}if(!t)throw new u(`Invalid organization path ${a}: no matching top-level entry for ${r}`)}}}async isEmptyOrganization(t){if((await this.server.ldap.search({paged:!1,filter:`(${this.config.ldap_organization_link_attribute}=${t})`})).searchEntries.length>0)throw new $(`Organization ${t} is not empty`)}isOu(t){if(!t.objectClass)return!1;const i=t.objectClass.map(t=>t.toLowerCase());return this.config.ldap_organization_class.filter(t=>"top"!==t.toLowerCase()).some(t=>i.includes(t.toLowerCase()))}async getOrganisationTop(t){if(!this.config.ldap_top_organization)throw new u("No top organization configured");const i=await this.server.ldap.search({paged:!1,scope:"base"},this.config.ldap_top_organization,t);if(1!==i.searchEntries.length)throw new w("Top organization not found");const[,a]=await g(this.registeredHooks.getOrganisationTop,[t,i.searchEntries[0]]);return a}async getOrganisationByDn(t,i){const a=await this.server.ldap.search({paged:!1,scope:"base",filter:"(objectClass=organizationalUnit)"},t,i);if(1!==a.searchEntries.length)throw new w(`Organization ${t} not found`);return a.searchEntries[0]}async getOrganisationSubnodes(t,i){const a=[],e=this.config.ldap_organization_max_subnodes||50,n=i?.query?.objectClass;if(!n||"organizationalUnit"===n)try{const e=await this.server.ldap.search({paged:!1,scope:"one",filter:"(objectClass=organizationalUnit)"},t,i);this.server.logger.debug(`Found ${e.searchEntries.length} sub-OUs for ${t}`),a.push(...e.searchEntries)}catch(i){this.server.logger.debug(`No sub-OUs found for ${t}: ${i}`)}const r=this.config.ldap_top_organization,s=l(r);let o=`(${this.config.ldap_organization_link_attribute}=${d(t)})`;n&&(o=`(&${o}(objectClass=${d(n)}))`),this.server.logger.debug(`Searching for linked entities with filter: ${o} in ${s}`);const h=await this.server.ldap.search({paged:!0,filter:o},s,i);let c=0,g=0;for await(const t of h){g+=t.searchEntries.length;const i=e-c;if(i>0){const e=t.searchEntries.slice(0,i);a.push(...e),c+=e.length}}return this.server.logger.debug(`Found ${g} linked entities for ${t}, returning ${c}`),g>e&&a.push({dn:`more-${t}`,cn:[`... ${g-e} more elements`],objectClass:["moreIndicator"],_isMoreIndicator:"true",_totalCount:g.toString(),_displayedCount:e.toString()}),a}async addOrganization(t,i,a){return this.validateNewOrganization(t,i),await this.server.ldap.add(t,i,a)}async modifyOrganization(t,i){return this.validateChanges(t,i),await this.server.ldap.modify(t,i)}validateNewOrganization(t,i){if(!this.schema)return!0;for(const[t,a]of Object.entries(i))if(!this._validateOneChange(t,a))throw new u(`Invalid value for field ${t}`);for(const[t,a]of Object.entries(this.schema.attributes))if(a.required&&null==i[t])throw new u(`Missing required field ${t}`);return!0}validateChanges(t,i){if(!this.schema)return!0;if(i.add)for(const[t,a]of Object.entries(i.add))if(!this._validateOneChange(t,a))throw new u(`Invalid value for field ${t}`);if(i.replace)for(const[t,a]of Object.entries(i.replace))if(!this._validateOneChange(t,a))throw new u(`Invalid value for field ${t}`);return!0}_validateOneChange(t,i){if(!this.schema)return!0;const a=this.schema.attributes[t];if(!a){if(this.schema.strict)throw new Error(`Field ${t} is not allowed`);return!0}if(null==i){if(a.required)throw new Error(`Field ${t} is required`);return!0}if(a.type)switch(a.type){case"string":if(Array.isArray(i))throw new Error(`Field ${t} must be a single value`);break;case"array":if(!Array.isArray(i))throw new Error(`Field ${t} must be an array`)}if(a.test){const e=Array.isArray(i)?i[0]:i;if(!("string"==typeof a.test?new RegExp(a.test):a.test).test(String(e)))throw new Error(`Field ${t} does not match required pattern`)}return!0}async moveOrganization(t,i,a){let e;try{e=await this.server.ldap.search({paged:!1,scope:"base"},i)}catch(t){throw new w(`Target organization ${i} not found: ${t}`)}if(!e.searchEntries||0===e.searchEntries.length)throw new w(`Target organization ${i} not found`);if(!this.isOu(e.searchEntries[0]))throw new u(`Target ${i} is not an organizational unit`);const n=`${p(t)},${i}`;let r;try{r=await this.server.ldap.search({paged:!1,scope:"base"},t)}catch(t){throw new w(`Source organization not found: ${t}`)}if(!r.searchEntries||0===r.searchEntries.length)throw new w(`Source organization ${t} not found`);if(n===t)throw new u("Cannot move organization to its current location");if(i.toLowerCase()===t.toLowerCase()||f(i,t))throw new u("Cannot move organization into itself or its descendant");try{await this.server.ldap.rename(t,n,a),this.logger.info(`Moved organization from ${t} to ${n}`)}catch(t){throw new Error(`Failed to move organization: ${t}`)}return{newDn:n}}async deleteOrganization(t){return await this.server.ldap.delete(t)}async searchOrganisationSubnodes(t,i){const a=[],e=d(i);try{const n=await this.server.ldap.search({paged:!1,scope:"one",filter:`(&(objectClass=organizationalUnit)(|(ou=*${e}*)(description=*${e}*)))`},t);this.server.logger.debug(`Found ${n.searchEntries.length} sub-OUs matching "${i}" for ${t}`),a.push(...n.searchEntries)}catch(a){this.server.logger.debug(`No sub-OUs found matching "${i}" for ${t}: ${a}`)}const n=this.config.ldap_top_organization,r=l(n),s=d(t),o=`(&(${this.config.ldap_organization_link_attribute}=${s})(|(uid=*${e}*)(cn=*${e}*)(mail=*${e}*)(sn=*${e}*)(givenName=*${e}*)))`;this.server.logger.debug(`Searching for linked entities with filter: ${o} in ${r}`);const h=await this.server.ldap.search({paged:!0,filter:o},r);let c=0;for await(const t of h)c+=t.searchEntries.length,a.push(...t.searchEntries);return this.server.logger.debug(`Found ${c} linked entities matching "${i}" for ${t}`),a}getConfigApiData(){const t=this.config.api_prefix||"/api";let i;if(this.server.loadedPlugins.static&&this.config.organization_schema){const t=this.config.static_name||"static",a=this.config.organization_schema.indexOf("/schemas/");if(-1!==a){i=`/${t}${this.config.organization_schema.substring(a)}`}}return{enabled:!0,topOrganization:this.config.ldap_top_organization||"",organizationClass:this.config.ldap_organization_class||["top","organizationalUnit"],linkAttribute:this.linkAttr||"",pathAttribute:this.pathAttr||"",pathSeparator:this.config.ldap_organization_path_separator||" / ",maxSubnodes:this.config.ldap_organization_max_subnodes||50,schema:this.schema,schemaUrl:i,endpoints:{getTop:`${t}/v1/ldap/organizations/top`,get:`${t}/v1/ldap/organizations/:dn`,getSubnodes:`${t}/v1/ldap/organizations/:dn/subnodes`,searchSubnodes:`${t}/v1/ldap/organizations/:dn/subnodes/search`,create:`${t}/v1/ldap/organizations`,update:`${t}/v1/ldap/organizations/:dn`,move:`${t}/v1/ldap/organizations/:dn/move`,delete:`${t}/v1/ldap/organizations/:dn`}}}}export{LdapOrganizations as default};
1
+ import t from"fs";import i from"../../abstract/plugin.js";import{tryMethodData as a,jsonBody as e,tryMethod as n,wantJson as r}from"../../lib/expressFormatedResponses.js";import{transformSchemas as s,asyncHandler as o,validateDnValue as h,escapeDnValue as c,escapeLdapFilter as d,launchHooksChained as g,getParentDn as l,getRdn as p,isChildOf as f}from"../../lib/utils.js";import{BadRequestError as u,NotFoundError as w,ConflictError as $}from"../../lib/errors.js";class LdapOrganizations extends i{constructor(i){if(super(i),this.name="ldapOrganizations",this.roles=["api","consistency","configurable"],this.hooks={ldapaddrequest:async([t,i,a])=>(this.isOu(i)||await this.checkDeptLink(i),this.isOu(i)&&await this.checkDeptPath(i),void 0!==a?[t,i,a]:[t,i]),ldapmodifyrequest:async([t,i,a])=>{let e,n={},r={};const checkIsOu=async()=>{if(void 0!==e)return e;if(i.replace?.objectClass)e=this.isOu({objectClass:i.replace.objectClass});else if(i.add?.objectClass)e=this.isOu({objectClass:i.add.objectClass});else{const i=await this.server.ldap.search({paged:!1,scope:"base"},t);e=i.searchEntries.length>0&&this.isOu(i.searchEntries[0])}return e};if(i.delete){const t=Array.isArray(i.delete)?i.delete.includes(this.linkAttr):i.delete[this.linkAttr],a=Array.isArray(i.delete)?i.delete.includes(this.pathAttr):i.delete[this.pathAttr];if(t||a){if(!await checkIsOu()&&t)throw new u("An organization link cannot be deleted");if(a)throw new u("An organization path cannot be deleted")}}if(i.replace&&(i.replace[this.linkAttr]&&(n={...i.replace}),i.replace[this.pathAttr]&&(r={...i.replace})),i.add&&(i.add[this.linkAttr]&&(n={...n,...i.add}),i.add[this.pathAttr]&&(r={...r,...i.add})),Object.keys(n).length>0){await checkIsOu()||await this.checkDeptLink(n)}if(Object.keys(r).length>0){await checkIsOu()&&await this.checkDeptPath(r)}return[t,i,a]},ldapdeleterequest:async([t,i])=>{const a=Array.isArray(t)?t:[t];for(const t of a)/^ou=/.test(t)&&await this.isEmptyOrganization(t);return[t,i]},ldaprenamerequest:([t,i])=>[t,i]},!this.config.ldap_top_organization)throw new Error("Missing --ldap-top-organization");this.pathAttr=this.config.ldap_organization_path_attribute,this.linkAttr=this.config.ldap_organization_link_attribute,this.config.organization_schema&&t.readFile(this.config.organization_schema,(t,i)=>{if(t)this.logger.error(`Failed to load organization schema from ${this.config.organization_schema}: ${t}`);else try{this.schema=JSON.parse(s(i.toString(),this.config)),this.logger.debug("Organization schema loaded")}catch(t){this.logger.error(`Failed to parse organization schema: ${t}`)}})}api(t){t.get(`${this.config.api_prefix}/v1/ldap/organizations/top`,async(t,i)=>{await a(i,this.getOrganisationTop.bind(this),t)}),t.get(`${this.config.api_prefix}/v1/ldap/organizations/:dn`,async(t,i)=>{const e=decodeURIComponent(t.params.dn);await a(i,this.getOrganisationByDn.bind(this),e,t)}),t.get(`${this.config.api_prefix}/v1/ldap/organizations/:dn/subnodes`,async(t,i)=>{const e=decodeURIComponent(t.params.dn);await a(i,this.getOrganisationSubnodes.bind(this),e,t)}),t.get(`${this.config.api_prefix}/v1/ldap/organizations/:dn/subnodes/search`,o(async(t,i)=>{const e=decodeURIComponent(t.params.dn),n=t.query.q;if(!n)throw new u('query parameter "q" is required');await a(i,this.searchOrganisationSubnodes.bind(this),e,n)})),t.post(`${this.config.api_prefix}/v1/ldap/organizations`,o(async(t,i)=>this.apiAdd(t,i))),t.put(`${this.config.api_prefix}/v1/ldap/organizations/:dn`,o(async(t,i)=>this.apiModify(t,i))),t.delete(`${this.config.api_prefix}/v1/ldap/organizations/:dn`,o(async(t,i)=>this.apiDelete(t,i))),t.post(`${this.config.api_prefix}/v1/ldap/organizations/:dn/move`,o(async(t,i)=>this.apiMove(t,i)))}async apiAdd(t,i){const a=e(t,i,"ou");if(!a)return;h(a.ou,"ou");const r=a.parentDn||this.config.ldap_top_organization,s=`ou=${c(a.ou)},${r}`,o={objectClass:this.config.ldap_organization_class,ou:a.ou,...Object.fromEntries(Object.entries(a).filter(([t])=>"ou"!==t&&"parentDn"!==t))};await n(i,this.addOrganization.bind(this),s,o,t)}async apiModify(t,i){const a=e(t,i);if(!a)return;const r=decodeURIComponent(t.params.dn);if(!r)throw new u("dn is required");await n(i,this.modifyOrganization.bind(this),r,a)}async apiDelete(t,i){if(!r(t,i))return;const a=decodeURIComponent(t.params.dn);if(!a)throw new u("dn is required");await n(i,this.deleteOrganization.bind(this),a)}async apiMove(t,i){if(!r(t,i))return;const n=e(t,i,"targetOrgDn");if(!n)return;const s=decodeURIComponent(t.params.dn);if(!s)throw new u("dn is required");const{targetOrgDn:o}=n;if(!o||"string"!=typeof o)throw new u("Missing or invalid targetOrgDn in request body");await a(i,this.moveOrganization.bind(this),s,o,t)}async checkDeptLink(t){if(t[this.linkAttr]){const i=t[this.linkAttr],a=Array.isArray(i)?i[0]:i,e=await this.server.ldap.search({paged:!1,scope:"base"},a);if(0===e.searchEntries.length)throw new w(`Organization ${a} does not exist`);if(!new RegExp(`(.*,)?${this.config.ldap_top_organization}`).test(e.searchEntries[0].dn))throw new u(`Entry ${a} isn't in top organization branch`)}}async checkDeptPath(t){if(t[this.pathAttr]){const i=t[this.pathAttr],a=Array.isArray(i)?i[0]:i,e=this.config.ldap_organization_path_separator||" / ";let n=a;if(this.isOu(t)){const i=t.ou,r=Array.isArray(i)?i[0]:i;if(!a.startsWith(r+e))throw new u(`Organization path must start with its own name followed by separator "${e}"`);n=a.slice(r.length+e.length)}const[r,s]=n.split(e,2),o=await this.server.ldap.search({paged:!1,filter:`(ou=${d(r)})`,scope:"sub"},this.config.ldap_top_organization);if(0===o.searchEntries.length)throw new u(`Invalid organization path ${a}`);if(s){let t=!1;for(const i of o.searchEntries){const a=i[this.pathAttr],e=Array.isArray(a)?a[0]:a;if(e&&e===s){t=!0;break}}if(!t)throw new u(`Invalid organization path ${a}: no matching entry for ${r} with path ${s}`)}else{let t=!1;for(const i of o.searchEntries){const a=i.dn,n=this.config.ldap_top_organization;if(a.toLowerCase()===n.toLowerCase()||a.toLowerCase().endsWith(`,${n.toLowerCase()}`)){const a=i[this.pathAttr],n=Array.isArray(a)?a[0]:a;if(!n||n===r||!n.includes(e)){t=!0;break}}}if(!t)throw new u(`Invalid organization path ${a}: no matching top-level entry for ${r}`)}}}async isEmptyOrganization(t){if((await this.server.ldap.search({paged:!1,filter:`(${this.config.ldap_organization_link_attribute}=${t})`})).searchEntries.length>0)throw new $(`Organization ${t} is not empty`)}isOu(t){if(!t.objectClass)return!1;const i=t.objectClass.map(t=>t.toLowerCase());return this.config.ldap_organization_class.filter(t=>"top"!==t.toLowerCase()).some(t=>i.includes(t.toLowerCase()))}async getOrganisationTop(t){if(!this.config.ldap_top_organization)throw new u("No top organization configured");const i=await this.server.ldap.search({paged:!1,scope:"base"},this.config.ldap_top_organization,t);if(1!==i.searchEntries.length)throw new w("Top organization not found");const[,a]=await g(this.registeredHooks.getOrganisationTop,[t,i.searchEntries[0]]);return a}async getOrganisationByDn(t,i){const a=await this.server.ldap.search({paged:!1,scope:"base",filter:"(objectClass=organizationalUnit)"},t,i);if(1!==a.searchEntries.length)throw new w(`Organization ${t} not found`);return a.searchEntries[0]}async getOrganisationSubnodes(t,i){const a=[],e=this.config.ldap_organization_max_subnodes||50,n=i?.query?.objectClass;if(!n||"organizationalUnit"===n)try{const e=await this.server.ldap.search({paged:!1,scope:"one",filter:"(objectClass=organizationalUnit)"},t,i);this.server.logger.debug(`Found ${e.searchEntries.length} sub-OUs for ${t}`),a.push(...e.searchEntries)}catch(i){this.server.logger.debug(`No sub-OUs found for ${t}: ${i}`)}const r=this.config.ldap_top_organization,s=l(r);let o=`(${this.config.ldap_organization_link_attribute}=${d(t)})`;n&&(o=`(&${o}(objectClass=${d(n)}))`),this.server.logger.debug(`Searching for linked entities with filter: ${o} in ${s}`);const h=await this.server.ldap.search({paged:!0,filter:o},s,i);let c=0,g=0;for await(const t of h){g+=t.searchEntries.length;const i=e-c;if(i>0){const e=t.searchEntries.slice(0,i);a.push(...e),c+=e.length}}return this.server.logger.debug(`Found ${g} linked entities for ${t}, returning ${c}`),g>e&&a.push({dn:`more-${t}`,cn:[`... ${g-e} more elements`],objectClass:["moreIndicator"],_isMoreIndicator:"true",_totalCount:g.toString(),_displayedCount:e.toString()}),a}async addOrganization(t,i,a){return this.validateNewOrganization(t,i),await this.server.ldap.add(t,i,a)}async modifyOrganization(t,i){return this.validateChanges(t,i),await this.server.ldap.modify(t,i)}validateNewOrganization(t,i){if(!this.schema)return!0;for(const[t,a]of Object.entries(i))if(!this._validateOneChange(t,a))throw new u(`Invalid value for field ${t}`);for(const[t,a]of Object.entries(this.schema.attributes))if(a.required&&null==i[t])throw new u(`Missing required field ${t}`);return!0}validateChanges(t,i){if(!this.schema)return!0;if(i.add)for(const[t,a]of Object.entries(i.add))if(!this._validateOneChange(t,a))throw new u(`Invalid value for field ${t}`);if(i.replace)for(const[t,a]of Object.entries(i.replace))if(!this._validateOneChange(t,a))throw new u(`Invalid value for field ${t}`);return!0}_validateOneChange(t,i){if(!this.schema)return!0;const a=this.schema.attributes[t];if(!a){if(this.schema.strict)throw new Error(`Field ${t} is not allowed`);return!0}if(null==i){if(a.required)throw new Error(`Field ${t} is required`);return!0}if(a.type)switch(a.type){case"string":if(Array.isArray(i))throw new Error(`Field ${t} must be a single value`);break;case"array":if(!Array.isArray(i))throw new Error(`Field ${t} must be an array`)}if(a.test){const e=Array.isArray(i)?i[0]:i;if(!("string"==typeof a.test?new RegExp(a.test):a.test).test(String(e)))throw new Error(`Field ${t} does not match required pattern`)}return!0}async moveOrganization(t,i,a){let e;try{e=await this.server.ldap.search({paged:!1,scope:"base"},i)}catch(t){throw new w(`Target organization ${i} not found: ${t}`)}if(!e.searchEntries||0===e.searchEntries.length)throw new w(`Target organization ${i} not found`);if(!this.isOu(e.searchEntries[0]))throw new u(`Target ${i} is not an organizational unit`);const n=`${p(t)},${i}`;let r;try{r=await this.server.ldap.search({paged:!1,scope:"base"},t)}catch(t){throw new w(`Source organization not found: ${t}`)}if(!r.searchEntries||0===r.searchEntries.length)throw new w(`Source organization ${t} not found`);if(n===t)throw new u("Cannot move organization to its current location");if(i.toLowerCase()===t.toLowerCase()||f(i,t))throw new u("Cannot move organization into itself or its descendant");try{await this.server.ldap.rename(t,n,a),this.logger.info(`Moved organization from ${t} to ${n}`)}catch(t){throw new Error(`Failed to move organization: ${t}`)}return{newDn:n}}async deleteOrganization(t){return await this.server.ldap.delete(t)}async searchOrganisationSubnodes(t,i){const a=[],e=d(i);try{const n=await this.server.ldap.search({paged:!1,scope:"one",filter:`(&(objectClass=organizationalUnit)(|(ou=*${e}*)(description=*${e}*)))`},t);this.server.logger.debug(`Found ${n.searchEntries.length} sub-OUs matching "${i}" for ${t}`),a.push(...n.searchEntries)}catch(a){this.server.logger.debug(`No sub-OUs found matching "${i}" for ${t}: ${a}`)}const n=this.config.ldap_top_organization,r=l(n),s=d(t),o=`(&(${this.config.ldap_organization_link_attribute}=${s})(|(uid=*${e}*)(cn=*${e}*)(mail=*${e}*)(sn=*${e}*)(givenName=*${e}*)))`;this.server.logger.debug(`Searching for linked entities with filter: ${o} in ${r}`);const h=await this.server.ldap.search({paged:!0,filter:o},r);let c=0;for await(const t of h)c+=t.searchEntries.length,a.push(...t.searchEntries);return this.server.logger.debug(`Found ${c} linked entities matching "${i}" for ${t}`),a}getConfigApiData(){const t=this.config.api_prefix||"/api";let i;if(this.server.loadedPlugins.static&&this.config.organization_schema){const t=this.config.static_name||"static",a=this.config.organization_schema.indexOf("/schemas/");if(-1!==a){i=`/${t}${this.config.organization_schema.substring(a)}`}}return{enabled:!0,topOrganization:this.config.ldap_top_organization||"",organizationClass:this.config.ldap_organization_class||["top","organizationalUnit"],linkAttribute:this.linkAttr||"",pathAttribute:this.pathAttr||"",pathSeparator:this.config.ldap_organization_path_separator||" / ",maxSubnodes:this.config.ldap_organization_max_subnodes||50,schema:this.schema,schemaUrl:i,endpoints:{getTop:`${t}/v1/ldap/organizations/top`,get:`${t}/v1/ldap/organizations/:dn`,getSubnodes:`${t}/v1/ldap/organizations/:dn/subnodes`,searchSubnodes:`${t}/v1/ldap/organizations/:dn/subnodes/search`,create:`${t}/v1/ldap/organizations`,update:`${t}/v1/ldap/organizations/:dn`,move:`${t}/v1/ldap/organizations/:dn/move`,delete:`${t}/v1/ldap/organizations/:dn`}}}}export{LdapOrganizations as default};
@@ -1 +1 @@
1
- import t from"../../abstract/plugin.js";class TrashPlugin extends t{constructor(t){super(t),this.name="trash",this.roles=["consistency"],this.watchedBases=[],this.trashBase="",this.addMetadata=!0,this.autoCreateTrash=!0,this.trashInitialized=!1,this.hooks={ldapdeleterequest:async t=>{const s=Array.isArray(t)?t:[t],a=[];for(const t of s){if(!this.isWatched(t)){this.logger.debug(`Trash plugin: ${t} not in watched branches, allowing native delete`),a.push(t);continue}this.logger.info(`Trash plugin: Intercepting delete of ${t}`),await this.ensureTrashExists();const s=this.buildTrashDN(t);await this.removeOldTrashEntry(s);try{this.logger.debug(`Moving ${t} to ${s} using LDAP move (atomic)`),await this.server.ldap.move(t,s),this.logger.info(`Entry ${t} moved to trash at ${s}`),await this.addMetadataToTrash(s,t)}catch(s){if(this.logger.error(`Trash plugin: Failed to move ${t} to trash: ${String(s)}`),s instanceof Error&&(s.message.includes("Insufficient access")||s.message.includes("permissions")||s.message.includes("access denied")))throw new Error(`Trash plugin: Insufficient LDAP permissions to move ${t} to trash. Please grant modifyDN permissions or disable trash plugin. Original error: ${s.message}`);throw new Error(`Trash plugin: Failed to move ${t} to trash: ${String(s)}`)}}return Array.isArray(t)?a:a[0]??void 0}};const s=this.config.trash_base;this.trashBase="string"==typeof s?s:"ou=trash,dc=example,dc=com";const a=this.config.trash_watched_bases;"string"==typeof a&&a&&(this.watchedBases=a.split(",").map(t=>t.trim()).filter(t=>t.length>0)),this.addMetadata="false"!==this.config.trash_add_metadata,this.autoCreateTrash="false"!==this.config.trash_auto_create,this.logger.info(`Trash plugin initialized: base=${this.trashBase}, watched=${this.watchedBases.join(",")}, metadata=${this.addMetadata}`)}isWatched(t){return t!==this.trashBase&&!t.endsWith(","+this.trashBase)&&(0===this.watchedBases.length||this.watchedBases.some(s=>t===s||t.endsWith(","+s)))}extractRDN(t){return t.split(",")[0]}buildTrashDN(t){return`${this.extractRDN(t)},${this.trashBase}`}async ensureTrashExists(){if(!this.trashInitialized)if(this.autoCreateTrash){try{if((await this.server.ldap.search({paged:!1},this.trashBase)).searchEntries.length>0)return this.logger.debug(`Trash branch ${this.trashBase} already exists`),void(this.trashInitialized=!0)}catch{this.logger.info(`Creating trash branch ${this.trashBase}`)}try{const t=this.extractRDN(this.trashBase).split("=")[1];await this.server.ldap.add(this.trashBase,{objectClass:["top","organizationalUnit"],ou:t,description:"LDAP Trash - Deleted entries are moved here"}),this.logger.info(`Trash branch ${this.trashBase} created successfully`),this.trashInitialized=!0}catch(t){throw this.logger.error(`Failed to create trash branch ${this.trashBase}: ${String(t)}`),new Error(`Trash plugin: Unable to create trash branch ${this.trashBase}. Please create it manually or set DM_TRASH_AUTO_CREATE=false`)}}else this.trashInitialized=!0}async removeOldTrashEntry(t){try{(await this.server.ldap.search({paged:!1},t)).searchEntries.length>0&&(this.logger.info(`Removing old trash entry ${t} before overwrite`),await this.server.ldap.delete(t),this.logger.debug(`Old trash entry ${t} deleted`))}catch(s){this.logger.debug(`No old trash entry to remove at ${t}: ${String(s)}`)}}async addMetadataToTrash(t,s){if(this.addMetadata)try{const a=`Deleted on ${(new Date).toISOString()} from ${s}`;await this.server.ldap.modify(t,{replace:{description:a}}),this.logger.debug(`Metadata added to ${t}`)}catch(s){this.logger.warn(`Failed to add metadata to ${t}: ${String(s)}. Entry moved to trash but without metadata.`)}}}export{TrashPlugin as default};
1
+ import t from"../../abstract/plugin.js";class TrashPlugin extends t{constructor(t){super(t),this.name="trash",this.roles=["consistency"],this.watchedBases=[],this.trashBase="",this.addMetadata=!0,this.autoCreateTrash=!0,this.trashInitialized=!1,this.hooks={ldapdeleterequest:async([t,s])=>{const a=Array.isArray(t)?t:[t],e=[];for(const t of a){if(!this.isWatched(t)){this.logger.debug(`Trash plugin: ${t} not in watched branches, allowing native delete`),e.push(t);continue}this.logger.info(`Trash plugin: Intercepting delete of ${t}`),await this.ensureTrashExists();const s=this.buildTrashDN(t);await this.removeOldTrashEntry(s);try{this.logger.debug(`Moving ${t} to ${s} using LDAP move (atomic)`),await this.server.ldap.move(t,s),this.logger.info(`Entry ${t} moved to trash at ${s}`),await this.addMetadataToTrash(s,t)}catch(s){if(this.logger.error(`Trash plugin: Failed to move ${t} to trash: ${String(s)}`),s instanceof Error&&(s.message.includes("Insufficient access")||s.message.includes("permissions")||s.message.includes("access denied")))throw new Error(`Trash plugin: Insufficient LDAP permissions to move ${t} to trash. Please grant modifyDN permissions or disable trash plugin. Original error: ${s.message}`);throw new Error(`Trash plugin: Failed to move ${t} to trash: ${String(s)}`)}}return[Array.isArray(t)?e:e[0]??void 0,s]}};const s=this.config.trash_base;this.trashBase="string"==typeof s?s:"ou=trash,dc=example,dc=com";const a=this.config.trash_watched_bases;"string"==typeof a&&a&&(this.watchedBases=a.split(",").map(t=>t.trim()).filter(t=>t.length>0)),this.addMetadata="false"!==this.config.trash_add_metadata,this.autoCreateTrash="false"!==this.config.trash_auto_create,this.logger.info(`Trash plugin initialized: base=${this.trashBase}, watched=${this.watchedBases.join(",")}, metadata=${this.addMetadata}`)}isWatched(t){return t!==this.trashBase&&!t.endsWith(","+this.trashBase)&&(0===this.watchedBases.length||this.watchedBases.some(s=>t===s||t.endsWith(","+s)))}extractRDN(t){return t.split(",")[0]}buildTrashDN(t){return`${this.extractRDN(t)},${this.trashBase}`}async ensureTrashExists(){if(!this.trashInitialized)if(this.autoCreateTrash){try{if((await this.server.ldap.search({paged:!1},this.trashBase)).searchEntries.length>0)return this.logger.debug(`Trash branch ${this.trashBase} already exists`),void(this.trashInitialized=!0)}catch{this.logger.info(`Creating trash branch ${this.trashBase}`)}try{const t=this.extractRDN(this.trashBase).split("=")[1];await this.server.ldap.add(this.trashBase,{objectClass:["top","organizationalUnit"],ou:t,description:"LDAP Trash - Deleted entries are moved here"}),this.logger.info(`Trash branch ${this.trashBase} created successfully`),this.trashInitialized=!0}catch(t){throw this.logger.error(`Failed to create trash branch ${this.trashBase}: ${String(t)}`),new Error(`Trash plugin: Unable to create trash branch ${this.trashBase}. Please create it manually or set DM_TRASH_AUTO_CREATE=false`)}}else this.trashInitialized=!0}async removeOldTrashEntry(t){try{(await this.server.ldap.search({paged:!1},t)).searchEntries.length>0&&(this.logger.info(`Removing old trash entry ${t} before overwrite`),await this.server.ldap.delete(t),this.logger.debug(`Old trash entry ${t} deleted`))}catch(s){this.logger.debug(`No old trash entry to remove at ${t}: ${String(s)}`)}}async addMetadataToTrash(t,s){if(this.addMetadata)try{const a=`Deleted on ${(new Date).toISOString()} from ${s}`;await this.server.ldap.modify(t,{replace:{description:a}}),this.logger.debug(`Metadata added to ${t}`)}catch(s){this.logger.warn(`Failed to add metadata to ${t}: ${String(s)}. Entry moved to trash but without metadata.`)}}}export{TrashPlugin as default};
@@ -1 +1 @@
1
- import{escapeDnValue as t,launchHooksChained as e,validateDnValue as s,launchHooks as r,isChildOf as i,escapeLdapFilter as a}from"../../lib/utils.js";import{SCHEMA_LIST_RESPONSE as o}from"./types.js";import{mergeMapping as n,loadMappingFile as c,DEFAULT_GROUP_MAPPING as h,requiredLdapAttributes as u,ldapToScimGroup as m,scimGroupToLdap as l}from"./mapping.js";import{scimFilterToLdap as p}from"./filter.js";import{patchToModifyRequest as d}from"./patch.js";import{extractLdapCode as g,scimNotFound as f,ScimError as b,scimTooMany as y,scimInvalidValue as w,scimUniqueness as $}from"./errors.js";class ScimGroups{constructor(t){this.ldap=t.ldap,this.config=t.config,this.logger=t.logger,this.baseResolver=t.baseResolver,this.users=t.users,this.hooks=t.hooks,this.rdnAttribute=this.config.scim_group_rdn_attribute||"cn",this.objectClass=this.config.scim_group_object_class,this.idAttribute=this.config.scim_id_attribute||"rdn",this.maxResults=this.config.scim_max_results||200,this.scimPrefix=this.config.scim_prefix||"/scim/v2";const e=this.config.scim_group_mapping||"";this.mapping=n(h,e?c(e):void 0)}ctx(t){return{idAttribute:this.idAttribute,rdnAttribute:this.rdnAttribute,resourceType:"Group",baseUrl:this.config.scim_base_url||(t?.protocol&&t.get?`${t.protocol}://${String(t.get("host")||"")}`:""),scimPrefix:this.scimPrefix}}dnForId(e,s){const r=this.baseResolver.groupBase(s);return`${this.rdnAttribute}=${t(e)},${r}`}buildMemberResolver(t){const e=this.baseResolver.userBase(t),s=this.baseResolver.groupBase(t),r=this.scimPrefix,i=this.config.scim_base_url||(t?.protocol&&t.get?`${t.protocol}://${String(t.get("host")||"")}`:""),a=this.config.scim_user_rdn_attribute||"uid",o=this.rdnAttribute,n=this.config.group_dummy_user||"cn=fakeuser";return t=>{if(!t)return;if(t.toLowerCase()===n.toLowerCase())return;const c=/^([^=]+)=((?:\\.|[^,])+)/.exec(t);if(!c)return;const h=c[2].replace(/\\(.)/g,"$1"),u=t.toLowerCase();let m;e&&u.endsWith(e.toLowerCase())?m="User":s&&u.endsWith(s.toLowerCase())?m="Group":c[1]===a?m="User":c[1]===o&&(m="Group");const l={value:h};if(m&&(l.type=m,i)){const t="User"===m?"Users":"Groups";l.$ref=`${i.replace(/\/$/,"")}${r}/${t}/${encodeURIComponent(h)}`}return l}}async get(t,e){const s=this.dnForId(e,t);let r;try{r=await this.ldap.search({paged:!1,scope:"base",attributes:[...u(this.mapping),"member"]},s)}catch(t){if(32===g(t))throw f(`Group ${e} not found`);throw t}if(!r.searchEntries||0===r.searchEntries.length)throw f(`Group ${e} not found`);return m(r.searchEntries[0],this.mapping,this.ctx(t),this.buildMemberResolver(t))}async list(t,e){const s=this.baseResolver.groupBase(t),r=Math.max(1,e.startIndex||1),i=Math.min(this.maxResults,Math.max(0,e.count??this.maxResults));let a,n=`(objectClass=${this.objectClass.find(t=>"top"!==t)||"groupOfNames"})`;if(e.filter){const t=p(e.filter,this.mapping);t.idEquals?a=t.idEquals:n=`(&${n}${t.ldapFilter})`}if(a)try{const e=await this.get(t,a);return{schemas:[o],totalResults:1,startIndex:1,itemsPerPage:1,Resources:[e]}}catch(t){if(t instanceof b&&404===t.statusCode)return{schemas:[o],totalResults:0,startIndex:1,itemsPerPage:0,Resources:[]};throw t}const c=(await this.ldap.search({filter:n,scope:"sub",paged:!1,attributes:[...u(this.mapping),"member"],sizeLimit:this.maxResults+1},s)).searchEntries||[],h=c.length;if(h>this.maxResults)throw y(`Filter matched ${h} resources, exceeds max ${this.maxResults}`);const l=c.slice(r-1,r-1+i),d=this.buildMemberResolver(t),g=l.map(e=>m(e,this.mapping,this.ctx(t),d));return{schemas:[o],totalResults:h,startIndex:r,itemsPerPage:g.length,Resources:g}}async create(i,a){if(!a.displayName)throw w("displayName is required");const o=(await e(this.hooks.scimgroupcreate,[a,i]))[0],{rdn:n,attributes:c}=l(o,this.mapping,this.objectClass);if(!n)throw w("displayName is required");s(n,this.rdnAttribute),c[this.rdnAttribute]=n;const h=[];if(o.members&&Array.isArray(o.members))for(const t of o.members){if(!t||!t.value)continue;const e=await this.users.resolveRef(i,t.value);e&&h.push(e)}if(0===h.length){const t=this.config.group_dummy_user||"cn=fakeuser";h.push(t)}c.member=h;const u=this.baseResolver.groupBase(i),m=`${this.rdnAttribute}=${t(n)},${u}`;try{await this.ldap.add(m,c)}catch(t){if(68===g(t))throw $(`Group ${n} already exists`);throw t}const p=await this.get(i,n);return r(this.hooks.scimgroupcreatedone,p),p}async replace(t,s,i){await this.get(t,s);const a=(await e(this.hooks.scimgroupupdate,[s,i,t]))[1],{attributes:o}=l(a,this.mapping,this.objectClass),n=[];if(a.members&&Array.isArray(a.members))for(const e of a.members){if(!e||!e.value)continue;const s=await this.users.resolveRef(t,e.value);s&&n.push(s)}if(0===n.length){const t=this.config.group_dummy_user||"cn=fakeuser";n.push(t)}const c=this.dnForId(s,t),h={replace:{}};for(const[t,e]of Object.entries(o))"objectClass"!==t&&t!==this.rdnAttribute&&(h.replace[t]=e);h.replace.member=n,await this.ldap.modify(c,h);const u=await this.get(t,s);return r(this.hooks.scimgroupupdatedone,s,u),u}async patch(t,e,s){await this.get(t,e);const i=await d(s,{mapping:this.mapping,memberAttribute:"member",resolveMemberRef:async e=>this.users.resolveRef(t,e)});if(!i.add&&!i.replace&&(!i.delete||Array.isArray(i.delete)&&0===i.delete.length))return this.get(t,e);const a=this.config.group_dummy_user||"cn=fakeuser",o=this.dnForId(e,t);if(i.delete&&!Array.isArray(i.delete)&&i.delete.member){const t=await this.ldap.search({paged:!1,scope:"base",attributes:["member"]},o),e=t.searchEntries[0]?.member,s=Array.isArray(e)?e:e?[String(e)]:[],r=Array.isArray(i.delete.member)?i.delete.member:[String(i.delete.member)];0===s.filter(t=>!r.includes(t)).length&&(i.add||(i.add={}),i.add.member=[a])}await this.ldap.modify(o,i);const n=await this.get(t,e);return r(this.hooks.scimgroupupdatedone,e,n),n}async delete(t,s){await this.get(t,s);const i=(await e(this.hooks.scimgroupdelete,[s,t]))[0],a=this.dnForId(i,t);await this.ldap.delete(a),r(this.hooks.scimgroupdeletedone,i)}async resolveRef(e,s){if(!s)return;const r=this.baseResolver.groupBase(e);if(s.includes("=")&&s.includes(","))return i(s,r)?s:void 0;const o=`${this.rdnAttribute}=${t(s)},${r}`;try{const t=await this.ldap.search({paged:!1,scope:"base",attributes:["dn"]},o);if(t.searchEntries&&t.searchEntries.length>0)return t.searchEntries[0].dn}catch{}try{const t=await this.ldap.search({filter:`(${this.rdnAttribute}=${a(s)})`,scope:"sub",paged:!1,attributes:["dn"]},r);if(t.searchEntries&&t.searchEntries.length>0)return t.searchEntries[0].dn}catch{}}get groupMapping(){return this.mapping}}export{ScimGroups};
1
+ import{escapeDnValue as t,launchHooksChained as e,validateDnValue as s,launchHooks as r,isChildOf as i,escapeLdapFilter as a}from"../../lib/utils.js";import{SCHEMA_LIST_RESPONSE as o}from"./types.js";import{mergeMapping as n,loadMappingFile as c,DEFAULT_GROUP_MAPPING as h,requiredLdapAttributes as u,ldapToScimGroup as m,scimGroupToLdap as l}from"./mapping.js";import{scimFilterToLdap as p}from"./filter.js";import{patchToModifyRequest as d}from"./patch.js";import{extractLdapCode as g,scimNotFound as f,ScimError as b,scimTooMany as y,scimInvalidValue as w,scimUniqueness as $}from"./errors.js";class ScimGroups{constructor(t){this.ldap=t.ldap,this.config=t.config,this.logger=t.logger,this.baseResolver=t.baseResolver,this.users=t.users,this.hooks=t.hooks,this.rdnAttribute=this.config.scim_group_rdn_attribute||"cn",this.objectClass=this.config.scim_group_object_class,this.idAttribute=this.config.scim_id_attribute||"rdn",this.maxResults=this.config.scim_max_results||200,this.scimPrefix=this.config.scim_prefix||"/scim/v2";const e=this.config.scim_group_mapping||"";this.mapping=n(h,e?c(e):void 0)}ctx(t){return{idAttribute:this.idAttribute,rdnAttribute:this.rdnAttribute,resourceType:"Group",baseUrl:this.config.scim_base_url||(t?.protocol&&t.get?`${t.protocol}://${String(t.get("host")||"")}`:""),scimPrefix:this.scimPrefix}}dnForId(e,s){const r=this.baseResolver.groupBase(s);return`${this.rdnAttribute}=${t(e)},${r}`}buildMemberResolver(t){const e=this.baseResolver.userBase(t),s=this.baseResolver.groupBase(t),r=this.scimPrefix,i=this.config.scim_base_url||(t?.protocol&&t.get?`${t.protocol}://${String(t.get("host")||"")}`:""),a=this.config.scim_user_rdn_attribute||"uid",o=this.rdnAttribute,n=this.config.group_dummy_user||"cn=fakeuser";return t=>{if(!t)return;if(t.toLowerCase()===n.toLowerCase())return;const c=/^([^=]+)=((?:\\.|[^,])+)/.exec(t);if(!c)return;const h=c[2].replace(/\\(.)/g,"$1"),u=t.toLowerCase();let m;e&&u.endsWith(e.toLowerCase())?m="User":s&&u.endsWith(s.toLowerCase())?m="Group":c[1]===a?m="User":c[1]===o&&(m="Group");const l={value:h};if(m&&(l.type=m,i)){const t="User"===m?"Users":"Groups";l.$ref=`${i.replace(/\/$/,"")}${r}/${t}/${encodeURIComponent(h)}`}return l}}async get(t,e){const s=this.dnForId(e,t);let r;try{r=await this.ldap.search({paged:!1,scope:"base",attributes:[...u(this.mapping),"member"]},s)}catch(t){if(32===g(t))throw f(`Group ${e} not found`);throw t}if(!r.searchEntries||0===r.searchEntries.length)throw f(`Group ${e} not found`);return m(r.searchEntries[0],this.mapping,this.ctx(t),this.buildMemberResolver(t))}async list(t,e){const s=this.baseResolver.groupBase(t),r=Math.max(1,e.startIndex||1),i=Math.min(this.maxResults,Math.max(0,e.count??this.maxResults));let a,n=`(objectClass=${this.objectClass.find(t=>"top"!==t)||"groupOfNames"})`;if(e.filter){const t=p(e.filter,this.mapping);t.idEquals?a=t.idEquals:n=`(&${n}${t.ldapFilter})`}if(a)try{const e=await this.get(t,a);return{schemas:[o],totalResults:1,startIndex:1,itemsPerPage:1,Resources:[e]}}catch(t){if(t instanceof b&&404===t.statusCode)return{schemas:[o],totalResults:0,startIndex:1,itemsPerPage:0,Resources:[]};throw t}const c=(await this.ldap.search({filter:n,scope:"sub",paged:!1,attributes:[...u(this.mapping),"member"],sizeLimit:this.maxResults+1},s)).searchEntries||[],h=c.length;if(h>this.maxResults)throw y(`Filter matched ${h} resources, exceeds max ${this.maxResults}`);const l=c.slice(r-1,r-1+i),d=this.buildMemberResolver(t),g=l.map(e=>m(e,this.mapping,this.ctx(t),d));return{schemas:[o],totalResults:h,startIndex:r,itemsPerPage:g.length,Resources:g}}async create(i,a){if(!a.displayName)throw w("displayName is required");const o=(await e(this.hooks.scimgroupcreate,[a,i]))[0],{rdn:n,attributes:c}=l(o,this.mapping,this.objectClass);if(!n)throw w("displayName is required");s(n,this.rdnAttribute),c[this.rdnAttribute]=n;const h=[];if(o.members&&Array.isArray(o.members))for(const t of o.members){if(!t||!t.value)continue;const e=await this.users.resolveRef(i,t.value);e&&h.push(e)}if(0===h.length){const t=this.config.group_dummy_user||"cn=fakeuser";h.push(t)}c.member=h;const u=this.baseResolver.groupBase(i),m=`${this.rdnAttribute}=${t(n)},${u}`;try{await this.ldap.add(m,c,i)}catch(t){if(68===g(t))throw $(`Group ${n} already exists`);throw t}const p=await this.get(i,n);return r(this.hooks.scimgroupcreatedone,p),p}async replace(t,s,i){await this.get(t,s);const a=(await e(this.hooks.scimgroupupdate,[s,i,t]))[1],{attributes:o}=l(a,this.mapping,this.objectClass),n=[];if(a.members&&Array.isArray(a.members))for(const e of a.members){if(!e||!e.value)continue;const s=await this.users.resolveRef(t,e.value);s&&n.push(s)}if(0===n.length){const t=this.config.group_dummy_user||"cn=fakeuser";n.push(t)}const c=this.dnForId(s,t),h={replace:{}};for(const[t,e]of Object.entries(o))"objectClass"!==t&&t!==this.rdnAttribute&&(h.replace[t]=e);h.replace.member=n,await this.ldap.modify(c,h,t);const u=await this.get(t,s);return r(this.hooks.scimgroupupdatedone,s,u),u}async patch(t,e,s){await this.get(t,e);const i=await d(s,{mapping:this.mapping,memberAttribute:"member",resolveMemberRef:async e=>this.users.resolveRef(t,e)});if(!i.add&&!i.replace&&(!i.delete||Array.isArray(i.delete)&&0===i.delete.length))return this.get(t,e);const a=this.config.group_dummy_user||"cn=fakeuser",o=this.dnForId(e,t);if(i.delete&&!Array.isArray(i.delete)&&i.delete.member){const t=await this.ldap.search({paged:!1,scope:"base",attributes:["member"]},o),e=t.searchEntries[0]?.member,s=Array.isArray(e)?e:e?[String(e)]:[],r=Array.isArray(i.delete.member)?i.delete.member:[String(i.delete.member)];0===s.filter(t=>!r.includes(t)).length&&(i.add||(i.add={}),i.add.member=[a])}await this.ldap.modify(o,i,t);const n=await this.get(t,e);return r(this.hooks.scimgroupupdatedone,e,n),n}async delete(t,s){await this.get(t,s);const i=(await e(this.hooks.scimgroupdelete,[s,t]))[0],a=this.dnForId(i,t);await this.ldap.delete(a,t),r(this.hooks.scimgroupdeletedone,i)}async resolveRef(e,s){if(!s)return;const r=this.baseResolver.groupBase(e);if(s.includes("=")&&s.includes(","))return i(s,r)?s:void 0;const o=`${this.rdnAttribute}=${t(s)},${r}`;try{const t=await this.ldap.search({paged:!1,scope:"base",attributes:["dn"]},o);if(t.searchEntries&&t.searchEntries.length>0)return t.searchEntries[0].dn}catch{}try{const t=await this.ldap.search({filter:`(${this.rdnAttribute}=${a(s)})`,scope:"sub",paged:!1,attributes:["dn"]},r);if(t.searchEntries&&t.searchEntries.length>0)return t.searchEntries[0].dn}catch{}}get groupMapping(){return this.mapping}}export{ScimGroups};
@@ -1 +1 @@
1
- import{escapeDnValue as t,launchHooksChained as s,validateDnValue as e,launchHooks as i,isChildOf as r,escapeLdapFilter as a}from"../../lib/utils.js";import{SCHEMA_LIST_RESPONSE as h}from"./types.js";import{mergeMapping as n,loadMappingFile as o,DEFAULT_USER_MAPPING as c,requiredLdapAttributes as l,ldapToScimUser as d,scimUserToLdap as u}from"./mapping.js";import{scimFilterToLdap as p}from"./filter.js";import{patchToModifyRequest as m}from"./patch.js";import{extractLdapCode as g,scimNotFound as f,ScimError as b,scimTooMany as w,scimInvalidValue as y,scimUniqueness as x}from"./errors.js";class ScimUsers{constructor(t){this.ldap=t.ldap,this.config=t.config,this.logger=t.logger,this.baseResolver=t.baseResolver,this.hooks=t.hooks,this.rdnAttribute=this.config.scim_user_rdn_attribute||"uid",this.objectClass=this.config.scim_user_object_class,this.idAttribute=this.config.scim_id_attribute||"rdn",this.maxResults=this.config.scim_max_results||200,this.scimPrefix=this.config.scim_prefix||"/scim/v2";const s=this.config.scim_user_mapping||"";this.mapping=n(c,s?o(s):void 0)}ctx(t){return{idAttribute:this.idAttribute,rdnAttribute:this.rdnAttribute,resourceType:"User",baseUrl:this.config.scim_base_url||(t?.protocol&&t.get?`${t.protocol}://${String(t.get("host")||"")}`:""),scimPrefix:this.scimPrefix}}dnForId(s,e){const i=this.baseResolver.userBase(e);return`${this.rdnAttribute}=${t(s)},${i}`}async get(t,s){const e=this.dnForId(s,t);let i;try{i=await this.ldap.search({paged:!1,scope:"base",attributes:l(this.mapping)},e)}catch(t){if(32===g(t))throw f(`User ${s} not found`);throw t}if(!i.searchEntries||0===i.searchEntries.length)throw f(`User ${s} not found`);return d(i.searchEntries[0],this.mapping,this.ctx(t))}async list(t,s){const e=this.baseResolver.userBase(t),i=Math.max(1,s.startIndex||1),r=Math.min(this.maxResults,Math.max(0,s.count??this.maxResults));let a,n=`(${this.objectClass.includes("inetOrgPerson")?"objectClass=inetOrgPerson":`objectClass=${this.objectClass[0]}`})`;if(s.filter){const t=p(s.filter,this.mapping);t.idEquals?a=t.idEquals:n=`(&${n}${t.ldapFilter})`}if(a)try{const s=await this.get(t,a);return{schemas:[h],totalResults:1,startIndex:1,itemsPerPage:1,Resources:[s]}}catch(t){if(t instanceof b&&404===t.statusCode)return{schemas:[h],totalResults:0,startIndex:1,itemsPerPage:0,Resources:[]};throw t}const o=(await this.ldap.search({filter:n,scope:"sub",paged:!1,attributes:l(this.mapping),sizeLimit:this.maxResults+1},e)).searchEntries||[],c=o.length;if(c>this.maxResults)throw w(`Filter matched ${c} resources, exceeds max ${this.maxResults}`);const u=o.slice(i-1,i-1+r);s.sortBy,s.sortOrder;const m=u.map(s=>d(s,this.mapping,this.ctx(t)));return{schemas:[h],totalResults:c,startIndex:i,itemsPerPage:m.length,Resources:m}}async create(r,a){if(!a.userName)throw y("userName is required");const h=(await s(this.hooks.scimusercreate,[a,r]))[0],{rdn:n,attributes:o}=u(h,this.mapping,this.ctx(r),this.objectClass);if(!n)throw y("userName is required");e(n,this.rdnAttribute),o[this.rdnAttribute]=n;const c=this.baseResolver.userBase(r),l=`${this.rdnAttribute}=${t(n)},${c}`;try{await this.ldap.add(l,o)}catch(t){if(68===g(t))throw x(`User ${n} already exists`);throw t}const d=await this.get(r,n);return i(this.hooks.scimusercreatedone,d),d}async replace(t,e,r){const a=this.dnForId(e,t),h=await this.ldap.search({paged:!1,scope:"base",attributes:l(this.mapping)},a);if(!h.searchEntries?.length)throw f(`User ${e} not found`);const n=h.searchEntries[0],o=(await s(this.hooks.scimuserupdate,[e,r,t]))[1],{attributes:c}=u(o,this.mapping,this.ctx(t),this.objectClass),d={replace:{},delete:[]},p=new Set(["objectClass","entryUUID","createTimestamp","modifyTimestamp",this.rdnAttribute]),hasAttrValue=t=>null!=t&&("string"==typeof t?t.length>0:!Array.isArray(t)||t.length>0&&t.some(t=>null!=t));for(const t of l(this.mapping))p.has(t)||(null!=c[t]?d.replace[t]=c[t]:hasAttrValue(n[t])&&d.delete.push(t));if(0===Object.keys(d.replace||{}).length&&delete d.replace,0===d.delete.length&&delete d.delete,d.replace||d.delete)try{await this.ldap.modify(a,d)}catch(t){if(16!==g(t))throw t}const m=await this.get(t,e);return i(this.hooks.scimuserupdatedone,e,m),m}async patch(t,s,e){await this.get(t,s);const r=await m(e,{mapping:this.mapping});if(!r.add&&!r.replace&&(!r.delete||Array.isArray(r.delete)&&0===r.delete.length))return this.get(t,s);const a=this.dnForId(s,t);await this.ldap.modify(a,r);const h=await this.get(t,s);return i(this.hooks.scimuserupdatedone,s,h),h}async delete(t,e){await this.get(t,e);const r=(await s(this.hooks.scimuserdelete,[e,t]))[0],a=this.dnForId(r,t);await this.ldap.delete(a),i(this.hooks.scimuserdeletedone,r)}async resolveRef(s,e){if(!e)return;const i=this.baseResolver.userBase(s);if(e.includes("=")&&e.includes(","))return r(e,i)?e:void 0;const h=`${this.rdnAttribute}=${t(e)},${i}`;try{const t=await this.ldap.search({paged:!1,scope:"base",attributes:["dn"]},h);if(t.searchEntries&&t.searchEntries.length>0)return t.searchEntries[0].dn}catch{}try{const t=await this.ldap.search({filter:`(${this.rdnAttribute}=${a(e)})`,scope:"sub",paged:!1,attributes:["dn"]},i);if(t.searchEntries&&t.searchEntries.length>0)return t.searchEntries[0].dn}catch{}}get userMapping(){return this.mapping}}export{ScimUsers};
1
+ import{escapeDnValue as t,launchHooksChained as s,validateDnValue as e,launchHooks as i,isChildOf as r,escapeLdapFilter as a}from"../../lib/utils.js";import{SCHEMA_LIST_RESPONSE as h}from"./types.js";import{mergeMapping as n,loadMappingFile as o,DEFAULT_USER_MAPPING as c,requiredLdapAttributes as l,ldapToScimUser as d,scimUserToLdap as u}from"./mapping.js";import{scimFilterToLdap as p}from"./filter.js";import{patchToModifyRequest as m}from"./patch.js";import{extractLdapCode as g,scimNotFound as f,ScimError as b,scimTooMany as w,scimInvalidValue as y,scimUniqueness as x}from"./errors.js";class ScimUsers{constructor(t){this.ldap=t.ldap,this.config=t.config,this.logger=t.logger,this.baseResolver=t.baseResolver,this.hooks=t.hooks,this.rdnAttribute=this.config.scim_user_rdn_attribute||"uid",this.objectClass=this.config.scim_user_object_class,this.idAttribute=this.config.scim_id_attribute||"rdn",this.maxResults=this.config.scim_max_results||200,this.scimPrefix=this.config.scim_prefix||"/scim/v2";const s=this.config.scim_user_mapping||"";this.mapping=n(c,s?o(s):void 0)}ctx(t){return{idAttribute:this.idAttribute,rdnAttribute:this.rdnAttribute,resourceType:"User",baseUrl:this.config.scim_base_url||(t?.protocol&&t.get?`${t.protocol}://${String(t.get("host")||"")}`:""),scimPrefix:this.scimPrefix}}dnForId(s,e){const i=this.baseResolver.userBase(e);return`${this.rdnAttribute}=${t(s)},${i}`}async get(t,s){const e=this.dnForId(s,t);let i;try{i=await this.ldap.search({paged:!1,scope:"base",attributes:l(this.mapping)},e)}catch(t){if(32===g(t))throw f(`User ${s} not found`);throw t}if(!i.searchEntries||0===i.searchEntries.length)throw f(`User ${s} not found`);return d(i.searchEntries[0],this.mapping,this.ctx(t))}async list(t,s){const e=this.baseResolver.userBase(t),i=Math.max(1,s.startIndex||1),r=Math.min(this.maxResults,Math.max(0,s.count??this.maxResults));let a,n=`(${this.objectClass.includes("inetOrgPerson")?"objectClass=inetOrgPerson":`objectClass=${this.objectClass[0]}`})`;if(s.filter){const t=p(s.filter,this.mapping);t.idEquals?a=t.idEquals:n=`(&${n}${t.ldapFilter})`}if(a)try{const s=await this.get(t,a);return{schemas:[h],totalResults:1,startIndex:1,itemsPerPage:1,Resources:[s]}}catch(t){if(t instanceof b&&404===t.statusCode)return{schemas:[h],totalResults:0,startIndex:1,itemsPerPage:0,Resources:[]};throw t}const o=(await this.ldap.search({filter:n,scope:"sub",paged:!1,attributes:l(this.mapping),sizeLimit:this.maxResults+1},e)).searchEntries||[],c=o.length;if(c>this.maxResults)throw w(`Filter matched ${c} resources, exceeds max ${this.maxResults}`);const u=o.slice(i-1,i-1+r);s.sortBy,s.sortOrder;const m=u.map(s=>d(s,this.mapping,this.ctx(t)));return{schemas:[h],totalResults:c,startIndex:i,itemsPerPage:m.length,Resources:m}}async create(r,a){if(!a.userName)throw y("userName is required");const h=(await s(this.hooks.scimusercreate,[a,r]))[0],{rdn:n,attributes:o}=u(h,this.mapping,this.ctx(r),this.objectClass);if(!n)throw y("userName is required");e(n,this.rdnAttribute),o[this.rdnAttribute]=n;const c=this.baseResolver.userBase(r),l=`${this.rdnAttribute}=${t(n)},${c}`;try{await this.ldap.add(l,o,r)}catch(t){if(68===g(t))throw x(`User ${n} already exists`);throw t}const d=await this.get(r,n);return i(this.hooks.scimusercreatedone,d),d}async replace(t,e,r){const a=this.dnForId(e,t),h=await this.ldap.search({paged:!1,scope:"base",attributes:l(this.mapping)},a);if(!h.searchEntries?.length)throw f(`User ${e} not found`);const n=h.searchEntries[0],o=(await s(this.hooks.scimuserupdate,[e,r,t]))[1],{attributes:c}=u(o,this.mapping,this.ctx(t),this.objectClass),d={replace:{},delete:[]},p=new Set(["objectClass","entryUUID","createTimestamp","modifyTimestamp",this.rdnAttribute]),hasAttrValue=t=>null!=t&&("string"==typeof t?t.length>0:!Array.isArray(t)||t.length>0&&t.some(t=>null!=t));for(const t of l(this.mapping))p.has(t)||(null!=c[t]?d.replace[t]=c[t]:hasAttrValue(n[t])&&d.delete.push(t));if(0===Object.keys(d.replace||{}).length&&delete d.replace,0===d.delete.length&&delete d.delete,d.replace||d.delete)try{await this.ldap.modify(a,d,t)}catch(t){if(16!==g(t))throw t}const m=await this.get(t,e);return i(this.hooks.scimuserupdatedone,e,m),m}async patch(t,s,e){await this.get(t,s);const r=await m(e,{mapping:this.mapping});if(!r.add&&!r.replace&&(!r.delete||Array.isArray(r.delete)&&0===r.delete.length))return this.get(t,s);const a=this.dnForId(s,t);await this.ldap.modify(a,r);const h=await this.get(t,s);return i(this.hooks.scimuserupdatedone,s,h),h}async delete(t,e){await this.get(t,e);const r=(await s(this.hooks.scimuserdelete,[e,t]))[0],a=this.dnForId(r,t);await this.ldap.delete(a,t),i(this.hooks.scimuserdeletedone,r)}async resolveRef(s,e){if(!e)return;const i=this.baseResolver.userBase(s);if(e.includes("=")&&e.includes(","))return r(e,i)?e:void 0;const h=`${this.rdnAttribute}=${t(e)},${i}`;try{const t=await this.ldap.search({paged:!1,scope:"base",attributes:["dn"]},h);if(t.searchEntries&&t.searchEntries.length>0)return t.searchEntries[0].dn}catch{}try{const t=await this.ldap.search({filter:`(${this.rdnAttribute}=${a(e)})`,scope:"sub",paged:!1,attributes:["dn"]},i);if(t.searchEntries&&t.searchEntries.length>0)return t.searchEntries[0].dn}catch{}}get userMapping(){return this.mapping}}export{ScimUsers};
@@ -1 +1 @@
1
- import{randomInt as t}from"crypto";import r from"../../abstract/plugin.js";import{wantJson as s}from"../../lib/expressFormatedResponses.js";import{escapeDnValue as e}from"../../lib/utils.js";class AppAccountsApi extends r{constructor(t){if(super(t),this.name="appAccountsApi",this.roles=["api","configurable"],this.dependencies={authToken:"core/auth/token",appAccountsConsistency:"core/twake/appAccountsConsistency"},this.applicativeAccountBase=this.config.applicative_account_base,this.maxAppAccounts=this.config.max_app_accounts||5,this.mailAttr=this.config.mail_attribute||"mail",!this.applicativeAccountBase)throw new Error(`${this.name}: applicative_account_base configuration is required`);this.logger.info(`${this.name}: initialized with applicative_account_base=${this.applicativeAccountBase}, max_app_accounts=${this.maxAppAccounts}`)}api(t){const r=this.config.api_prefix||"/api";t.get(`${r}/v1/users/:user/app-accounts`,(t,r)=>this.listAccounts(t,r)),t.post(`${r}/v1/users/:user/app-accounts`,(t,r)=>this.createAccount(t,r)),t.delete(`${r}/v1/users/:user/app-accounts/:uid`,(t,r)=>this.deleteAccount(t,r)),this.logger.info(`AppAccounts API registered at ${r}/v1/users/:user/app-accounts`)}async listAccounts(t,r){if(!s(t,r))return;const e=t.params.user;try{const t=await this.server.ldap.search({scope:"sub",filter:`(uid=${e})`,paged:!1,attributes:[this.mailAttr]},this.config.ldap_base||""),s=t.searchEntries?.[0];if(!s)return void r.status(404).json({error:`User ${e} not found`});const i=s[this.mailAttr];if(!i)return void r.status(400).json({error:`User ${e} has no mail attribute`});const a=Array.isArray(i)?String(i[0]):String(i),o=await this.server.ldap.search({scope:"sub",filter:`(${this.mailAttr}=${a})`,paged:!1},this.applicativeAccountBase),n=(o.searchEntries||[]).filter(t=>{const r=t.uid,s=Array.isArray(r)?String(r[0]):String(r);return s!==a&&s.startsWith(`${e}_`)}).map(t=>{const r=t.uid,s=Array.isArray(r)?String(r[0]):String(r),e=t.description,i=e?Array.isArray(e)?String(e[0]):String(e):void 0;return{uid:s,...i?{name:i}:{}}}).sort((t,r)=>t.uid.localeCompare(r.uid));r.json(n)}catch(t){this.logger.error(`${this.name}: Failed to list accounts for ${e}:`,t),r.status(500).json({error:"Internal server error"})}}async createAccount(t,r){if(!s(t,r))return;const i=t.params.user,{name:a}=t.body||{};try{const t=await this.server.ldap.search({scope:"sub",filter:`(uid=${i})`,paged:!1},this.config.ldap_base||""),s=t.searchEntries?.[0];if(!s)return void r.status(404).json({error:`User ${i} not found`});const o=s[this.mailAttr];if(!o)return void r.status(400).json({error:`User ${i} has no mail attribute`});const n=Array.isArray(o)?String(o[0]):String(o),c=await this.server.ldap.search({scope:"sub",filter:`(${this.mailAttr}=${n})`,paged:!1},this.applicativeAccountBase),u=(c.searchEntries||[]).filter(t=>{const r=t.uid,s=Array.isArray(r)?String(r[0]):String(r);return s!==n&&s.startsWith(`${i}_`)});if(u.length>=this.maxAppAccounts)return void r.status(400).json({error:`Maximum number of accounts (${this.maxAppAccounts}) reached`});let p,l=0;do{if(p=this.generateAccountId(),l++,l>100)throw new Error("Failed to generate unique account ID after 100 attempts")}while(u.some(t=>{const r=t.uid;return(Array.isArray(r)?String(r[0]):String(r))===`${i}_${p}`}));const d=`${i}_${p}`,h=this.generatePassword(),A={objectClass:["inetOrgPerson"],uid:d,userPassword:h},g=["cn","sn","givenName",this.mailAttr,"displayName"];for(const t of g)if(s[t]){const r=s[t];Array.isArray(r)?A[t]=r.map(t=>Buffer.isBuffer(t)?t.toString():String(t)):A[t]=Buffer.isBuffer(r)?r.toString():String(r)}a&&(A.description=a);const f=`uid=${e(d)},${this.applicativeAccountBase}`;await this.server.ldap.add(f,A),this.logger.info(`${this.name}: Created applicative account ${f} for user ${i}`);const m=`uid=${e(n)},${this.applicativeAccountBase}`;try{await this.server.ldap.modify(m,{add:{userPassword:h}})}catch(t){this.logger.warn(`${this.name}: Failed to add password to principal account ${m}:`,t)}r.json({uid:d,pwd:h,mail:n})}catch(t){this.logger.error(`${this.name}: Failed to create account for ${i}:`,t),r.status(500).json({error:"Internal server error"})}}async deleteAccount(t,r){if(!s(t,r))return;const i=t.params.user,a=t.params.uid;try{if(!a.startsWith(`${i}_`))return void r.status(403).json({error:`Account ${a} does not belong to user ${i}`});const t=await this.server.ldap.search({scope:"sub",filter:`(uid=${a})`,paged:!1},this.applicativeAccountBase),s=t.searchEntries?.[0];if(!s)return this.logger.warn(`${this.name}: Account ${a} already deleted or not found`),void r.json({uid:a});const o=s.userPassword;o||this.logger.warn(`${this.name}: Account ${a} has no userPassword attribute`);const n=s[this.mailAttr],c=n?Array.isArray(n)?String(n[0]):String(n):null;if(c&&o){const t=`uid=${e(c)},${this.applicativeAccountBase}`;try{const r=Array.isArray(o)?o[0]:o;await this.server.ldap.modify(t,{delete:{userPassword:r}})}catch(r){this.logger.warn(`${this.name}: Failed to delete password from principal account ${t}:`,r)}}const u=s.dn;await this.server.ldap.delete(u),this.logger.info(`${this.name}: Deleted applicative account ${u}`),r.json({uid:a})}catch(t){this.logger.error(`${this.name}: Failed to delete account ${a} for ${i}:`,t),r.status(500).json({error:"Internal server error"})}}generateAccountId(){return`c${t(0,1e8).toString().padStart(8,"0")}`}generatePassword(){const r="ABCDEFGHJKMNPQRSTUVWXYZ",s="abcdefghjkmnpqrstuvwxyz",e="23456789",i="@!#$%",a=[];for(let o=0;o<6;o++){const o=t(0,23),n=t(0,23),c=t(0,8),u=t(0,5),p=[r[o],s[n],e[c],i[u]];for(let r=p.length-1;r>0;r--){const s=t(0,r+1);[p[r],p[s]]=[p[s],p[r]]}a.push(p.join(""))}return a.join("-")}getConfigApiData(){const t=this.config.api_prefix||"/api";return{enabled:!0,base:this.applicativeAccountBase,maxAccounts:this.maxAppAccounts,mailAttribute:this.mailAttr,endpoints:{list:`${t}/v1/users/:user/app-accounts`,create:`${t}/v1/users/:user/app-accounts`,delete:`${t}/v1/users/:user/app-accounts/:uid`}}}}export{AppAccountsApi as default};
1
+ import{randomInt as t}from"crypto";import r from"../../abstract/plugin.js";import{wantJson as s}from"../../lib/expressFormatedResponses.js";import{escapeLdapFilter as e,isDnInBranch as i,escapeDnValue as a}from"../../lib/utils.js";class AppAccountsApi extends r{constructor(t){if(super(t),this.name="appAccountsApi",this.roles=["api","configurable"],this.dependencies={appAccountsConsistency:"core/twake/appAccountsConsistency"},this.applicativeAccountBase=this.config.applicative_account_base,this.maxAppAccounts=this.config.max_app_accounts||5,this.mailAttr=this.config.mail_attribute||"mail",this.userAttr=this.config.app_accounts_user_attribute||this.mailAttr,!this.applicativeAccountBase)throw new Error(`${this.name}: applicative_account_base configuration is required`);this.logger.info(`${this.name}: initialized with applicative_account_base=${this.applicativeAccountBase}, max_app_accounts=${this.maxAppAccounts}, user_attribute=${this.userAttr}`)}api(t){const r=this.config.api_prefix||"/api";t.get(`${r}/v1/users/:user/app-accounts`,(t,r)=>this.listAccounts(t,r)),t.post(`${r}/v1/users/:user/app-accounts`,(t,r)=>this.createAccount(t,r)),t.delete(`${r}/v1/users/:user/app-accounts/:uid`,(t,r)=>this.deleteAccount(t,r)),this.logger.info(`AppAccounts API registered at ${r}/v1/users/:user/app-accounts`)}async resolvePrincipal(t,r,s){const a=((await this.server.ldap.search({scope:"sub",filter:`(${this.userAttr}=${e(t)})`,paged:!1,...s?{attributes:s}:{}},this.config.ldap_base||"")).searchEntries||[]).filter(t=>!i(t.dn,this.applicativeAccountBase));return 0===a.length?(r.status(404).json({error:`User ${t} not found`}),null):a.length>1?(this.logger.error(`${this.name}: Ambiguous principal lookup for ${t}: ${a.length} entries share ${this.userAttr}`),r.status(409).json({error:`Multiple users share ${this.userAttr} ${t}`}),null):a[0]}async listAccounts(t,r){if(!s(t,r))return;const i=t.params.user;try{const t=await this.resolvePrincipal(i,r,[this.mailAttr]);if(!t)return;const s=t[this.mailAttr];if(!s)return void r.status(400).json({error:`User ${i} has no mail attribute`});const a=Array.isArray(s)?String(s[0]):String(s),n=await this.server.ldap.search({scope:"sub",filter:`(${this.mailAttr}=${e(a)})`,paged:!1},this.applicativeAccountBase),o=(n.searchEntries||[]).filter(t=>{const r=t.uid;return(Array.isArray(r)?String(r[0]):String(r)).toLowerCase()!==a.toLowerCase()}).map(t=>{const r=t.uid,s=Array.isArray(r)?String(r[0]):String(r),e=t.description,i=e?Array.isArray(e)?String(e[0]):String(e):void 0;return{uid:s,...i?{name:i}:{}}}).sort((t,r)=>t.uid.localeCompare(r.uid));r.json(o)}catch(t){this.logger.error(`${this.name}: Failed to list accounts for ${i}:`,t),r.status(500).json({error:"Internal server error"})}}async createAccount(t,r){if(!s(t,r))return;const i=t.params.user,{name:n}=t.body||{};try{const t=await this.resolvePrincipal(i,r);if(!t)return;const s=t[this.mailAttr];if(!s)return void r.status(400).json({error:`User ${i} has no mail attribute`});const o=Array.isArray(s)?String(s[0]):String(s),c=this.appUidPrefix(i),u=(await this.server.ldap.search({scope:"sub",filter:`(${this.mailAttr}=${e(o)})`,paged:!1},this.applicativeAccountBase)).searchEntries||[];if(u.filter(t=>{const r=t.uid;return(Array.isArray(r)?String(r[0]):String(r)).toLowerCase()!==o.toLowerCase()}).length>=this.maxAppAccounts)return void r.status(400).json({error:`Maximum number of accounts (${this.maxAppAccounts}) reached`});let p,l=0;do{if(p=`${c}_${this.generateAccountId()}`,l++,l>100)throw new Error("Failed to generate unique account ID after 100 attempts")}while(await this.uidExists(p));const h=this.generatePassword(),d={objectClass:["inetOrgPerson"],uid:p,userPassword:h},A=["cn","sn","givenName",this.mailAttr,"displayName"];for(const r of A)if(t[r]){const s=t[r];Array.isArray(s)?d[r]=s.map(t=>Buffer.isBuffer(t)?t.toString():String(t)):d[r]=Buffer.isBuffer(s)?s.toString():String(s)}n&&(d.description=n);const g=`uid=${a(p)},${this.applicativeAccountBase}`;await this.server.ldap.add(g,d),this.logger.info(`${this.name}: Created applicative account ${g} for user ${i}`);const f=`uid=${a(o)},${this.applicativeAccountBase}`;try{await this.server.ldap.modify(f,{add:{userPassword:h}})}catch(t){this.logger.warn(`${this.name}: Failed to add password to principal account ${f}:`,t)}r.json({uid:p,pwd:h,mail:o})}catch(t){this.logger.error(`${this.name}: Failed to create account for ${i}:`,t),r.status(500).json({error:"Internal server error"})}}appUidPrefix(t){return t.replace(/[^A-Za-z0-9_-]/g,"_")}async uidExists(t){return((await this.server.ldap.search({scope:"sub",filter:`(uid=${e(t)})`,paged:!1,attributes:["uid"]},this.applicativeAccountBase)).searchEntries||[]).length>0}async deleteAccount(t,r){if(!s(t,r))return;const i=t.params.user,n=t.params.uid;try{const t=await this.server.ldap.search({scope:"sub",filter:`(uid=${e(n)})`,paged:!1},this.applicativeAccountBase),s=t.searchEntries?.[0];if(!s)return this.logger.warn(`${this.name}: Account ${n} already deleted or not found`),void r.json({uid:n});const o=s[this.mailAttr],c=o?Array.isArray(o)?String(o[0]):String(o):null;let u=i;if(this.userAttr!==this.mailAttr){const t=await this.resolvePrincipal(i,r,[this.mailAttr]);if(!t)return;const s=t[this.mailAttr],e=s?Array.isArray(s)?String(s[0]):String(s):null;if(!e)return void r.status(400).json({error:`User ${i} has no mail attribute`});u=e}if(!c||c.toLowerCase()!==u.toLowerCase())return void r.status(403).json({error:`Account ${n} does not belong to user ${i}`});const p=s.userPassword;if(p||this.logger.warn(`${this.name}: Account ${n} has no userPassword attribute`),p){const t=`uid=${a(c)},${this.applicativeAccountBase}`;try{const r=Array.isArray(p)?p[0]:p;await this.server.ldap.modify(t,{delete:{userPassword:r}})}catch(r){this.logger.warn(`${this.name}: Failed to delete password from principal account ${t}:`,r)}}const l=s.dn;await this.server.ldap.delete(l),this.logger.info(`${this.name}: Deleted applicative account ${l}`),r.json({uid:n})}catch(t){this.logger.error(`${this.name}: Failed to delete account ${n} for ${i}:`,t),r.status(500).json({error:"Internal server error"})}}generateAccountId(){return`c${t(0,1e8).toString().padStart(8,"0")}`}generatePassword(){const r="ABCDEFGHJKMNPQRSTUVWXYZ",s="abcdefghjkmnpqrstuvwxyz",e="23456789",i="@!#$%",a=[];for(let n=0;n<6;n++){const n=t(0,23),o=t(0,23),c=t(0,8),u=t(0,5),p=[r[n],s[o],e[c],i[u]];for(let r=p.length-1;r>0;r--){const s=t(0,r+1);[p[r],p[s]]=[p[s],p[r]]}a.push(p.join(""))}return a.join("-")}getConfigApiData(){const t=this.config.api_prefix||"/api";return{enabled:!0,base:this.applicativeAccountBase,maxAccounts:this.maxAppAccounts,mailAttribute:this.mailAttr,endpoints:{list:`${t}/v1/users/:user/app-accounts`,create:`${t}/v1/users/:user/app-accounts`,delete:`${t}/v1/users/:user/app-accounts/:uid`}}}}export{AppAccountsApi as default};
@@ -1 +1 @@
1
- import t from"../../abstract/plugin.js";import{escapeDnValue as e}from"../../lib/utils.js";class AppAccountsConsistency extends t{constructor(t){if(super(t),this.name="appAccountsConsistency",this.roles=["consistency"],this.dependencies={onLdapChange:"core/ldap/onChange"},this.hooks={onLdapMailChange:async(t,e,i)=>{if(this.isInApplicativeBranch(t))this.logger.debug(`${this.name}: Ignoring mail change event originating in the applicative branch`);else try{const a=null!=e?Array.isArray(e)?String(e[0]):String(e):null,s=null!=i?Array.isArray(i)?String(i[0]):String(i):null;if(!a&&s)return void await this.createApplicativeAccount(t,s);if(a&&!s)return void await this.deleteApplicativeAccount(a);if(a&&s&&a!==s)return void await this.updateApplicativeAccount(t,a,s)}catch(e){this.logger.error(`${this.name}: Failed to handle mail change for ${t}:`,e)}}},this.mailAttr=this.config.mail_attribute||"mail",this.applicativeAccountBase=this.config.applicative_account_base,this.operationalAttributes=this.config.ldap_operational_attribute||[],!this.applicativeAccountBase)throw new Error(`${this.name}: applicative_account_base configuration is required`);this.logger.info(`${this.name}: initialized with applicative_account_base=${this.applicativeAccountBase}`)}shouldExcludeAttribute(t){return"dn"===t||this.operationalAttributes.includes(t)}isInApplicativeBranch(t){const norm=t=>t.toLowerCase().replace(/\s*,\s*/g,",").trim(),e=norm(this.applicativeAccountBase),i=norm(t);return i===e||i.endsWith(`,${e}`)}async createApplicativeAccount(t,i){const a=`uid=${e(i)},${this.applicativeAccountBase}`;try{const e=await this.server.ldap.search({scope:"base",paged:!1},t),s=e.searchEntries?.[0];if(!s)return void this.logger.warn(`${this.name}: Could not find user ${t} to create applicative account`);const c={};for(const[t,e]of Object.entries(s))this.shouldExcludeAttribute(t)||null!=e&&(Array.isArray(e)&&0===e.length||(c[t]=e));c.uid=i,await this.server.ldap.add(a,c),this.logger.info(`${this.name}: Created applicative account ${a} for user ${t}`)}catch(e){if(68===e.code||e.message?.includes("AlreadyExists"))return void this.logger.debug(`${this.name}: Applicative account ${a} already exists`);this.logger.error(`${this.name}: Failed to create applicative account for ${t}:`,e)}}async deleteApplicativeAccount(t){try{const e=`(${this.mailAttr}=${t})`,i=(await this.server.ldap.search({filter:e,scope:"sub",paged:!1},this.applicativeAccountBase)).searchEntries||[];if(0===i.length)return void this.logger.debug(`${this.name}: No applicative accounts found for mail ${t}`);for(const t of i){const e=t.dn;try{await this.server.ldap.delete(e),this.logger.info(`${this.name}: Deleted applicative account ${e}`)}catch(t){32===t.code||t.message?.includes("NoSuchObject")?this.logger.debug(`${this.name}: Applicative account ${e} already deleted`):this.logger.error(`${this.name}: Failed to delete applicative account ${e}:`,t)}}}catch(e){if(32===e.code||e.message?.includes("NoSuchObject"))return void this.logger.debug(`${this.name}: Applicative account base does not exist or no accounts found for mail ${t}`);this.logger.error(`${this.name}: Failed to delete applicative account for mail ${t}:`,e)}}async updateApplicativeAccount(t,i,a){try{const s=`(${this.mailAttr}=${i})`,c=(await this.server.ldap.search({filter:s,scope:"sub",paged:!1},this.applicativeAccountBase)).searchEntries||[];if(0===c.length)return void this.logger.debug(`${this.name}: No applicative account found for old mail ${i}`);for(const s of c){const c=s.dn,r=Array.isArray(s.uid)?String(s.uid[0]):String(s.uid),o=r===i;let n;n=o?`uid=${e(a)},${this.applicativeAccountBase}`:`uid=${e(r)},${this.applicativeAccountBase}`;try{const e={};for(const[t,i]of Object.entries(s))this.shouldExcludeAttribute(t)||null!=i&&(Array.isArray(i)&&0===i.length||(e[t]=i));try{await this.server.ldap.delete(c),this.logger.info(`${this.name}: Deleted old applicative account ${c}`)}catch(t){if(32!==t.code&&!t.message?.includes("NoSuchObject"))throw t;this.logger.debug(`${this.name}: Applicative account ${c} already deleted`)}const l={...e},h=await this.server.ldap.search({scope:"base",paged:!1},t),u=h.searchEntries?.[0];if(!u)return void this.logger.warn(`${this.name}: Could not find user ${t} to update applicative account`);for(const[t,e]of Object.entries(u))this.shouldExcludeAttribute(t)||null!=e&&(Array.isArray(e)&&0===e.length||(l[t]=e));l.uid=o?a:r,await this.server.ldap.add(n,l),this.logger.info(`${this.name}: Created new applicative account ${n} (mail changed from ${i} to ${a})`)}catch(t){if(68===t.code||t.message?.includes("AlreadyExists"))return void this.logger.debug(`${this.name}: Applicative account ${n} already exists after mail change`);this.logger.error(`${this.name}: Failed to update applicative account for mail change:`,t)}}}catch(e){this.logger.error(`${this.name}: Failed to update applicative account for ${t}:`,e)}}}export{AppAccountsConsistency as default};
1
+ import t from"../../abstract/plugin.js";import{isDnInBranch as e,escapeDnValue as i}from"../../lib/utils.js";class AppAccountsConsistency extends t{constructor(t){if(super(t),this.name="appAccountsConsistency",this.roles=["consistency"],this.dependencies={onLdapChange:"core/ldap/onChange"},this.hooks={onLdapMailChange:async(t,e,i)=>{if(this.isInApplicativeBranch(t))this.logger.debug(`${this.name}: Ignoring mail change event originating in the applicative branch`);else try{const a=null!=e?Array.isArray(e)?String(e[0]):String(e):null,c=null!=i?Array.isArray(i)?String(i[0]):String(i):null;if(!a&&c)return void await this.createApplicativeAccount(t,c);if(a&&!c)return void await this.deleteApplicativeAccount(a);if(a&&c&&a!==c)return void await this.updateApplicativeAccount(t,a,c)}catch(e){this.logger.error(`${this.name}: Failed to handle mail change for ${t}:`,e)}}},this.mailAttr=this.config.mail_attribute||"mail",this.applicativeAccountBase=this.config.applicative_account_base,this.operationalAttributes=this.config.ldap_operational_attribute||[],!this.applicativeAccountBase)throw new Error(`${this.name}: applicative_account_base configuration is required`);this.logger.info(`${this.name}: initialized with applicative_account_base=${this.applicativeAccountBase}`)}shouldExcludeAttribute(t){return"dn"===t||this.operationalAttributes.includes(t)}isInApplicativeBranch(t){return e(t,this.applicativeAccountBase)}async createApplicativeAccount(t,e){const a=`uid=${i(e)},${this.applicativeAccountBase}`;try{const i=await this.server.ldap.search({scope:"base",paged:!1},t),c=i.searchEntries?.[0];if(!c)return void this.logger.warn(`${this.name}: Could not find user ${t} to create applicative account`);const s={};for(const[t,e]of Object.entries(c))this.shouldExcludeAttribute(t)||null!=e&&(Array.isArray(e)&&0===e.length||(s[t]=e));s.uid=e,await this.server.ldap.add(a,s),this.logger.info(`${this.name}: Created applicative account ${a} for user ${t}`)}catch(e){if(68===e.code||e.message?.includes("AlreadyExists"))return void this.logger.debug(`${this.name}: Applicative account ${a} already exists`);this.logger.error(`${this.name}: Failed to create applicative account for ${t}:`,e)}}async deleteApplicativeAccount(t){try{const e=`(${this.mailAttr}=${t})`,i=(await this.server.ldap.search({filter:e,scope:"sub",paged:!1},this.applicativeAccountBase)).searchEntries||[];if(0===i.length)return void this.logger.debug(`${this.name}: No applicative accounts found for mail ${t}`);for(const t of i){const e=t.dn;try{await this.server.ldap.delete(e),this.logger.info(`${this.name}: Deleted applicative account ${e}`)}catch(t){32===t.code||t.message?.includes("NoSuchObject")?this.logger.debug(`${this.name}: Applicative account ${e} already deleted`):this.logger.error(`${this.name}: Failed to delete applicative account ${e}:`,t)}}}catch(e){if(32===e.code||e.message?.includes("NoSuchObject"))return void this.logger.debug(`${this.name}: Applicative account base does not exist or no accounts found for mail ${t}`);this.logger.error(`${this.name}: Failed to delete applicative account for mail ${t}:`,e)}}async updateApplicativeAccount(t,e,a){try{const c=`(${this.mailAttr}=${e})`,s=(await this.server.ldap.search({filter:c,scope:"sub",paged:!1},this.applicativeAccountBase)).searchEntries||[];if(0===s.length)return void this.logger.debug(`${this.name}: No applicative account found for old mail ${e}`);for(const c of s){const s=c.dn,r=Array.isArray(c.uid)?String(c.uid[0]):String(c.uid),n=r===e;let o;o=n?`uid=${i(a)},${this.applicativeAccountBase}`:`uid=${i(r)},${this.applicativeAccountBase}`;try{const i={};for(const[t,e]of Object.entries(c))this.shouldExcludeAttribute(t)||null!=e&&(Array.isArray(e)&&0===e.length||(i[t]=e));try{await this.server.ldap.delete(s),this.logger.info(`${this.name}: Deleted old applicative account ${s}`)}catch(t){if(32!==t.code&&!t.message?.includes("NoSuchObject"))throw t;this.logger.debug(`${this.name}: Applicative account ${s} already deleted`)}const l={...i},h=await this.server.ldap.search({scope:"base",paged:!1},t),u=h.searchEntries?.[0];if(!u)return void this.logger.warn(`${this.name}: Could not find user ${t} to update applicative account`);for(const[t,e]of Object.entries(u))this.shouldExcludeAttribute(t)||null!=e&&(Array.isArray(e)&&0===e.length||(l[t]=e));l.uid=n?a:r,await this.server.ldap.add(o,l),this.logger.info(`${this.name}: Created new applicative account ${o} (mail changed from ${e} to ${a})`)}catch(t){if(68===t.code||t.message?.includes("AlreadyExists"))return void this.logger.debug(`${this.name}: Applicative account ${o} already exists after mail change`);this.logger.error(`${this.name}: Failed to update applicative account for mail change:`,t)}}}catch(e){this.logger.error(`${this.name}: Failed to update applicative account for ${t}:`,e)}}}export{AppAccountsConsistency as default};
@@ -150,6 +150,7 @@ export interface Config {
150
150
  rabbitmq_url?: string;
151
151
  applicative_account_base?: string;
152
152
  max_app_accounts?: number;
153
+ app_accounts_user_attribute?: string;
153
154
  ldap_operational_attribute?: string[];
154
155
  trash_base?: string;
155
156
  trash_watched_bases?: string;
@@ -26,7 +26,7 @@ export interface Hooks {
26
26
  ldapadddone?: (args: [string, AttributesList]) => MaybePromise<void>;
27
27
  ldapmodifyrequest?: ChainedHook<[string, ModifyRequest, number, Request?]>;
28
28
  ldapmodifydone?: (args: [string, ModifyRequest, number]) => MaybePromise<void>;
29
- ldapdeleterequest?: ChainedHook<string | string[]>;
29
+ ldapdeleterequest?: ChainedHook<[string | string[], Request?]>;
30
30
  ldapdeletedone?: (dn: string | string[]) => MaybePromise<void>;
31
31
  ldaprenamerequest?: ChainedHook<[string, string, Request?]>;
32
32
  ldaprenamedone?: (args: [string, string]) => MaybePromise<void>;
@@ -59,5 +59,6 @@ export default abstract class AuthzBase extends DmPlugin {
59
59
  ldapsearchrequest: ([base, opts, req]: [string, SearchOptions, DmRequest?]) => Promise<[string, SearchOptions, DmRequest?]>;
60
60
  getOrganisationTop: ([req, defaultTop]: [DmRequest | undefined, AttributesList | null]) => Promise<[DmRequest | undefined, AttributesList | null]>;
61
61
  ldaprenamerequest: ([oldDn, newDn, req]: [string, string, DmRequest?]) => Promise<[string, string, DmRequest?]>;
62
+ ldapdeleterequest: ([dn, req]: [string | string[], DmRequest?]) => Promise<[string | string[], DmRequest?]>;
62
63
  };
63
64
  }
@@ -91,7 +91,7 @@ declare class ldapActions {
91
91
  * ldapts will extract newRDN="uid=user1" and newSuperior="ou=trash,dc=example,dc=com"
92
92
  */
93
93
  move(dn: string, newDn: string): Promise<boolean>;
94
- delete(dn: string | string[]): Promise<boolean>;
94
+ delete(dn: string | string[], req?: Request): Promise<boolean>;
95
95
  private setDn;
96
96
  opNumber(): number;
97
97
  }
@@ -158,6 +158,51 @@ export declare function getRdn(dn: string): string;
158
158
  * ```
159
159
  */
160
160
  export declare function isChildOf(dn: string, parentDn: string): boolean;
161
+ /**
162
+ * Normalize a DN into a canonical, comparable form.
163
+ *
164
+ * Parses the DN into RDNs (honouring escaped commas via {@link parseDn}), then
165
+ * for each RDN lowercases it, collapses the optional whitespace around the `=`
166
+ * of every attribute-value assertion, and sorts the assertions of a
167
+ * multi-valued RDN so their ordering is irrelevant. The RDNs are re-joined with
168
+ * `,`. Empty components (e.g. from a trailing separator) are dropped.
169
+ *
170
+ * This makes equality / suffix comparisons robust to case, surrounding
171
+ * whitespace and multi-valued RDN ordering — unlike a raw string match.
172
+ *
173
+ * @param dn - The DN to normalize
174
+ * @returns The canonical form
175
+ *
176
+ * @example
177
+ * ```typescript
178
+ * normalizeDn('UID=x, ou=AppAccounts ,dc=Example,dc=com')
179
+ * // => 'uid=x,ou=appaccounts,dc=example,dc=com'
180
+ * ```
181
+ */
182
+ export declare function normalizeDn(dn: string): string;
183
+ /**
184
+ * Check whether a DN is a branch base itself or sits anywhere below it.
185
+ *
186
+ * Comparison is done RDN by RDN on the {@link normalizeDn} form, so it is
187
+ * robust to case, whitespace and escaped separators. A naive string suffix
188
+ * match can give a false negative (e.g. `ou=AppAccounts` vs `ou=appaccounts`,
189
+ * or `uid=x, ou=…` with stray whitespace), which is exactly what lets a
190
+ * re-entrant LDAP change event slip through a branch guard.
191
+ *
192
+ * Unlike {@link isChildOf}, this returns true when `dn` equals `base` as well.
193
+ *
194
+ * @param dn - The candidate DN
195
+ * @param base - The branch base DN
196
+ * @returns true if `dn` equals `base` or is a descendant of it
197
+ *
198
+ * @example
199
+ * ```typescript
200
+ * isDnInBranch('uid=x,ou=app,dc=e,dc=c', 'ou=app,dc=e,dc=c') // => true
201
+ * isDnInBranch('ou=app,dc=e,dc=c', 'ou=app,dc=e,dc=c') // => true
202
+ * isDnInBranch('uid=x,ou=users,dc=e,dc=c', 'ou=app,dc=e,dc=c') // => false
203
+ * ```
204
+ */
205
+ export declare function isDnInBranch(dn: string, base: string): boolean;
161
206
  /**
162
207
  * Wrapper for async Express route handlers to catch errors and pass them to error middleware
163
208
  * This ensures that errors in async routes are properly handled and don't crash the server
@@ -16,8 +16,10 @@ import type { DM } from '../../bin';
16
16
  * type: string
17
17
  * description: |
18
18
  * Unique identifier of the app account. Format:
19
- * `<username>_c<8-digits>` (e.g. `alice_c04729183`).
20
- * example: alice_c04729183
19
+ * `<prefix>_c<8-digits>`, where `<prefix>` is the `:user` path param
20
+ * (the resolution value — the mail by default) sanitized into a
21
+ * uid-safe token (e.g. `alice@example.com` -> `alice_example_com`).
22
+ * example: alice_example_com_c04729183
21
23
  * name:
22
24
  * type: string
23
25
  * description: Human-readable label (stored as LDAP `description`).
@@ -68,17 +70,35 @@ export default class AppAccountsApi extends DmPlugin {
68
70
  name: string;
69
71
  roles: Role[];
70
72
  dependencies: {
71
- authToken: string;
72
73
  appAccountsConsistency: string;
73
74
  };
74
75
  private applicativeAccountBase;
75
76
  private maxAppAccounts;
76
77
  private mailAttr;
78
+ private userAttr;
77
79
  constructor(server: DM);
78
80
  /**
79
81
  * Register API routes
80
82
  */
81
83
  api(app: Express): void;
84
+ /**
85
+ * Resolve the principal user entry from the `:user` path param.
86
+ *
87
+ * `:user` is matched against the configured resolution attribute
88
+ * (`app_accounts_user_attribute`, default the mail attribute). Mail is
89
+ * globally unique; resolving by the LDAP `uid` instead is only safe when uid
90
+ * is unique directory-wide, as the same `uid` may repeat under different
91
+ * subtrees and resolve to an arbitrary same-named user (see #88).
92
+ *
93
+ * On failure (no match, or an ambiguous one) this writes the HTTP error
94
+ * response and returns null, so callers can simply `return` when it does.
95
+ *
96
+ * @param principal - The `:user` path param value
97
+ * @param res - The Express response, used to emit error statuses
98
+ * @param attributes - Optional attribute projection for the search
99
+ * @returns The single matching LDAP entry, or null when none/ambiguous
100
+ */
101
+ private resolvePrincipal;
82
102
  /**
83
103
  * List applicative accounts for a user
84
104
  */
@@ -87,6 +107,17 @@ export default class AppAccountsApi extends DmPlugin {
87
107
  * Create a new applicative account
88
108
  */
89
109
  private createAccount;
110
+ /**
111
+ * Derive a uid-safe prefix for app-account uids from the (unique) `:user`
112
+ * value. Characters awkward in a uid/RDN (e.g. `@` and `.` in a mail) are
113
+ * replaced with `_`. Sanitization may be lossy, but app-account uids are made
114
+ * unique across the whole branch at creation time, so this never collides.
115
+ */
116
+ private appUidPrefix;
117
+ /**
118
+ * Whether a uid already exists anywhere under the applicative branch.
119
+ */
120
+ private uidExists;
90
121
  /**
91
122
  * Delete an applicative account
92
123
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ldap-rest",
3
- "version": "0.3.9",
3
+ "version": "0.4.0",
4
4
  "description": "Light directory manager",
5
5
  "keywords": [
6
6
  "Identity-Manager"