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.
- package/bin/sync-app-accounts.mjs +68 -25
- package/dist/bin/index.js +1 -1
- package/dist/lib/authz/base.js +1 -1
- package/dist/lib/ldapActions.js +1 -1
- package/dist/lib/utils.js +1 -1
- package/dist/plugins/auth/authzDynamic.js +1 -1
- package/dist/plugins/ldap/groups.js +1 -1
- package/dist/plugins/ldap/onChange.js +1 -1
- package/dist/plugins/ldap/organizations.js +1 -1
- package/dist/plugins/ldap/trash.js +1 -1
- package/dist/plugins/scim/groups.js +1 -1
- package/dist/plugins/scim/users.js +1 -1
- package/dist/plugins/twake/appAccountsApi.js +1 -1
- package/dist/plugins/twake/appAccountsConsistency.js +1 -1
- package/dist/src/config/args.d.ts +1 -0
- package/dist/src/hooks.d.ts +1 -1
- package/dist/src/lib/authz/base.d.ts +1 -0
- package/dist/src/lib/ldapActions.d.ts +1 -1
- package/dist/src/lib/utils.d.ts +45 -0
- package/dist/src/plugins/twake/appAccountsApi.d.ts +34 -3
- package/package.json +1 -1
|
@@ -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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
process.exit(
|
|
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};
|
package/dist/lib/authz/base.js
CHANGED
|
@@ -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
|
|
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};
|
package/dist/lib/ldapActions.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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;
|
package/dist/src/hooks.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/src/lib/utils.d.ts
CHANGED
|
@@ -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
|
-
* `<
|
|
20
|
-
*
|
|
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
|
*/
|