0b701dfc6f
- OAuth Apps and LDAP Info are both "how do other apps/hosts plug into this SSO" concerns -- merged into a single /integrations page with tabs, replacing the two separate nav items with one. /oauth-clients and /ldap-info 301-redirect there for compat. - Add a Service Accounts section under the LDAP tab: bind-only LDAP identities (organizationalRole + simpleSecurityObject, no posixAccount) for apps/hosts, as opposed to real people. Create, rotate password, and delete, all from the UI -- previously the only such account (theta-env's bootstrap-created cn=ldapclient) was invisible to the Users page entirely (filtered out by conf.ldap.userFilter) and had no GUI way to see or rotate it; the new ServiceAccount model uses the exact same objectClasses bootstrap.js already creates cn=ldapclient with, so it recognizes and manages that account too, not just ones created through this UI. - The ldap-client bash snippet now points at "create one under Service Accounts above" instead of a bare textual example. Verified against a real LDAP server (not just the dev sandbox's usual unreachable one): created a service account, confirmed it binds successfully with the generated password, rotated its password, and deleted it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
115 lines
3.6 KiB
JavaScript
115 lines
3.6 KiB
JavaScript
'use strict';
|
|
|
|
// Non-person "service" accounts under ou=people -- bind-only LDAP identities
|
|
// for things like theta-env's bootstrap-created cn=ldapclient (the proxy's
|
|
// direct-LDAP bind account) or any other app/host that needs its own
|
|
// dedicated read-only credential, as opposed to a real user who logs into
|
|
// the web UI.
|
|
//
|
|
// Deliberately NOT posixAccount/inetOrgPerson (the User model's shape) --
|
|
// these can't log into the SSO Manager UI or get a home directory/uidNumber.
|
|
// objectClass matches exactly what theta-env's bootstrap.js already creates
|
|
// for cn=ldapclient, so this model recognizes and manages that account too,
|
|
// not just ones created through this UI.
|
|
|
|
const { Client, Attribute, Change } = require('ldapts');
|
|
const crypto = require('crypto');
|
|
const conf = require('@simpleworkjs/conf').ldap;
|
|
|
|
function hashPasswordSSHA512(password) {
|
|
const salt = crypto.randomBytes(8);
|
|
const hash = crypto.createHash('sha512').update(password).update(salt).digest();
|
|
return '{SSHA512}' + Buffer.concat([hash, salt]).toString('base64');
|
|
}
|
|
|
|
function makeClient() {
|
|
return new Client({ url: conf.url });
|
|
}
|
|
|
|
async function withClient(fn) {
|
|
const client = makeClient();
|
|
try {
|
|
await client.bind(conf.bindDN, conf.bindPassword);
|
|
return await fn(client);
|
|
} finally {
|
|
await client.unbind().catch(() => {});
|
|
}
|
|
}
|
|
|
|
const FILTER = '(&(objectClass=organizationalRole)(objectClass=simpleSecurityObject))';
|
|
const CN_RE = /^[A-Za-z][A-Za-z0-9._-]{1,63}$/;
|
|
|
|
var ServiceAccount = {};
|
|
|
|
ServiceAccount.list = async function(){
|
|
return withClient(async (client) => {
|
|
const res = await client.search(conf.userBase, {
|
|
scope: 'sub',
|
|
filter: FILTER,
|
|
attributes: ['cn', 'description', 'createTimestamp', 'modifyTimestamp'],
|
|
});
|
|
return res.searchEntries.map((entry) => ({
|
|
cn: entry.cn,
|
|
dn: `cn=${entry.cn},${conf.userBase}`,
|
|
description: entry.description || '',
|
|
created_on: entry.createTimestamp || null,
|
|
modified_on: entry.modifyTimestamp || null,
|
|
})).sort((a, b) => a.cn.localeCompare(b.cn));
|
|
});
|
|
};
|
|
|
|
ServiceAccount.create = async function({cn, description}){
|
|
if(!cn || !CN_RE.test(cn)){
|
|
throw Object.assign(new Error('InvalidName'), {status: 400, message: 'Name must start with a letter and contain only letters, numbers, dot, dash, underscore.'});
|
|
}
|
|
|
|
const dn = `cn=${cn},${conf.userBase}`;
|
|
const password = crypto.randomBytes(24).toString('base64url');
|
|
|
|
await withClient(async (client) => {
|
|
let existing = true;
|
|
try{
|
|
const res = await client.search(dn, {scope: 'base', filter: '(objectClass=*)', attributes: ['dn']});
|
|
existing = res.searchEntries.length > 0;
|
|
}catch(error){ existing = false; }
|
|
if(existing){
|
|
throw Object.assign(new Error('NameInUse'), {status: 409, message: `"${cn}" already exists under ${conf.userBase}.`});
|
|
}
|
|
|
|
await client.add(dn, {
|
|
objectClass: ['organizationalRole', 'simpleSecurityObject', 'top'],
|
|
cn,
|
|
description: description || '',
|
|
userPassword: hashPasswordSSHA512(password),
|
|
});
|
|
});
|
|
|
|
return {cn, dn, description: description || '', password};
|
|
};
|
|
|
|
ServiceAccount.setPassword = async function(cn, password){
|
|
const dn = `cn=${cn},${conf.userBase}`;
|
|
const newPassword = password || crypto.randomBytes(24).toString('base64url');
|
|
|
|
await withClient(async (client) => {
|
|
await client.modify(dn, [
|
|
new Change({
|
|
operation: 'replace',
|
|
modification: new Attribute({type: 'userPassword', values: [hashPasswordSSHA512(newPassword)]}),
|
|
}),
|
|
]);
|
|
});
|
|
|
|
return {cn, dn, password: newPassword};
|
|
};
|
|
|
|
ServiceAccount.remove = async function(cn){
|
|
const dn = `cn=${cn},${conf.userBase}`;
|
|
await withClient(async (client) => {
|
|
await client.del(dn);
|
|
});
|
|
return true;
|
|
};
|
|
|
|
module.exports = {ServiceAccount};
|