ldap-rest 0.3.9 → 0.3.10
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/utils.js +1 -1
- package/dist/plugins/twake/appAccountsApi.js +1 -1
- package/dist/plugins/twake/appAccountsConsistency.js +1 -1
- package/dist/src/lib/utils.d.ts +45 -0
- package/dist/src/plugins/twake/appAccountsApi.d.ts +0 -1
- 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/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{randomInt as t}from"crypto";import r from"../../abstract/plugin.js";import{wantJson as s}from"../../lib/expressFormatedResponses.js";import{escapeDnValue as e}from"../../lib/utils.js";class AppAccountsApi extends r{constructor(t){if(super(t),this.name="appAccountsApi",this.roles=["api","configurable"],this.dependencies={
|
|
1
|
+
import{randomInt as t}from"crypto";import r from"../../abstract/plugin.js";import{wantJson as s}from"../../lib/expressFormatedResponses.js";import{escapeDnValue as e}from"../../lib/utils.js";class AppAccountsApi extends r{constructor(t){if(super(t),this.name="appAccountsApi",this.roles=["api","configurable"],this.dependencies={appAccountsConsistency:"core/twake/appAccountsConsistency"},this.applicativeAccountBase=this.config.applicative_account_base,this.maxAppAccounts=this.config.max_app_accounts||5,this.mailAttr=this.config.mail_attribute||"mail",!this.applicativeAccountBase)throw new Error(`${this.name}: applicative_account_base configuration is required`);this.logger.info(`${this.name}: initialized with applicative_account_base=${this.applicativeAccountBase}, max_app_accounts=${this.maxAppAccounts}`)}api(t){const r=this.config.api_prefix||"/api";t.get(`${r}/v1/users/:user/app-accounts`,(t,r)=>this.listAccounts(t,r)),t.post(`${r}/v1/users/:user/app-accounts`,(t,r)=>this.createAccount(t,r)),t.delete(`${r}/v1/users/:user/app-accounts/:uid`,(t,r)=>this.deleteAccount(t,r)),this.logger.info(`AppAccounts API registered at ${r}/v1/users/:user/app-accounts`)}async listAccounts(t,r){if(!s(t,r))return;const e=t.params.user;try{const t=await this.server.ldap.search({scope:"sub",filter:`(uid=${e})`,paged:!1,attributes:[this.mailAttr]},this.config.ldap_base||""),s=t.searchEntries?.[0];if(!s)return void r.status(404).json({error:`User ${e} not found`});const i=s[this.mailAttr];if(!i)return void r.status(400).json({error:`User ${e} has no mail attribute`});const a=Array.isArray(i)?String(i[0]):String(i),n=await this.server.ldap.search({scope:"sub",filter:`(${this.mailAttr}=${a})`,paged:!1},this.applicativeAccountBase),o=(n.searchEntries||[]).filter(t=>{const r=t.uid,s=Array.isArray(r)?String(r[0]):String(r);return s!==a&&s.startsWith(`${e}_`)}).map(t=>{const r=t.uid,s=Array.isArray(r)?String(r[0]):String(r),e=t.description,i=e?Array.isArray(e)?String(e[0]):String(e):void 0;return{uid:s,...i?{name:i}:{}}}).sort((t,r)=>t.uid.localeCompare(r.uid));r.json(o)}catch(t){this.logger.error(`${this.name}: Failed to list accounts for ${e}:`,t),r.status(500).json({error:"Internal server error"})}}async createAccount(t,r){if(!s(t,r))return;const i=t.params.user,{name:a}=t.body||{};try{const t=await this.server.ldap.search({scope:"sub",filter:`(uid=${i})`,paged:!1},this.config.ldap_base||""),s=t.searchEntries?.[0];if(!s)return void r.status(404).json({error:`User ${i} not found`});const n=s[this.mailAttr];if(!n)return void r.status(400).json({error:`User ${i} has no mail attribute`});const o=Array.isArray(n)?String(n[0]):String(n),c=await this.server.ldap.search({scope:"sub",filter:`(${this.mailAttr}=${o})`,paged:!1},this.applicativeAccountBase),u=(c.searchEntries||[]).filter(t=>{const r=t.uid,s=Array.isArray(r)?String(r[0]):String(r);return s!==o&&s.startsWith(`${i}_`)});if(u.length>=this.maxAppAccounts)return void r.status(400).json({error:`Maximum number of accounts (${this.maxAppAccounts}) reached`});let p,l=0;do{if(p=this.generateAccountId(),l++,l>100)throw new Error("Failed to generate unique account ID after 100 attempts")}while(u.some(t=>{const r=t.uid;return(Array.isArray(r)?String(r[0]):String(r))===`${i}_${p}`}));const d=`${i}_${p}`,h=this.generatePassword(),A={objectClass:["inetOrgPerson"],uid:d,userPassword:h},g=["cn","sn","givenName",this.mailAttr,"displayName"];for(const t of g)if(s[t]){const r=s[t];Array.isArray(r)?A[t]=r.map(t=>Buffer.isBuffer(t)?t.toString():String(t)):A[t]=Buffer.isBuffer(r)?r.toString():String(r)}a&&(A.description=a);const f=`uid=${e(d)},${this.applicativeAccountBase}`;await this.server.ldap.add(f,A),this.logger.info(`${this.name}: Created applicative account ${f} for user ${i}`);const m=`uid=${e(o)},${this.applicativeAccountBase}`;try{await this.server.ldap.modify(m,{add:{userPassword:h}})}catch(t){this.logger.warn(`${this.name}: Failed to add password to principal account ${m}:`,t)}r.json({uid:d,pwd:h,mail:o})}catch(t){this.logger.error(`${this.name}: Failed to create account for ${i}:`,t),r.status(500).json({error:"Internal server error"})}}async deleteAccount(t,r){if(!s(t,r))return;const i=t.params.user,a=t.params.uid;try{if(!a.startsWith(`${i}_`))return void r.status(403).json({error:`Account ${a} does not belong to user ${i}`});const t=await this.server.ldap.search({scope:"sub",filter:`(uid=${a})`,paged:!1},this.applicativeAccountBase),s=t.searchEntries?.[0];if(!s)return this.logger.warn(`${this.name}: Account ${a} already deleted or not found`),void r.json({uid:a});const n=s.userPassword;n||this.logger.warn(`${this.name}: Account ${a} has no userPassword attribute`);const o=s[this.mailAttr],c=o?Array.isArray(o)?String(o[0]):String(o):null;if(c&&n){const t=`uid=${e(c)},${this.applicativeAccountBase}`;try{const r=Array.isArray(n)?n[0]:n;await this.server.ldap.modify(t,{delete:{userPassword:r}})}catch(r){this.logger.warn(`${this.name}: Failed to delete password from principal account ${t}:`,r)}}const u=s.dn;await this.server.ldap.delete(u),this.logger.info(`${this.name}: Deleted applicative account ${u}`),r.json({uid:a})}catch(t){this.logger.error(`${this.name}: Failed to delete account ${a} for ${i}:`,t),r.status(500).json({error:"Internal server error"})}}generateAccountId(){return`c${t(0,1e8).toString().padStart(8,"0")}`}generatePassword(){const r="ABCDEFGHJKMNPQRSTUVWXYZ",s="abcdefghjkmnpqrstuvwxyz",e="23456789",i="@!#$%",a=[];for(let 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};
|
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
|