Unify service accounts to one kind, add manager field, make homeDirectory/loginShell editable

Removes the LDAP bind-only service account type in favor of a single
Unix/POSIX account model, surfaced in a new Users > Service Accounts tab.
Adds a multi-valued `manager` field to every account (defaults to the
creator, editable, and grants edit rights on the accounts a person manages
without needing app_sso_admin). homeDirectory and loginShell are now
editable from the profile edit form.

Bumps to v1.1.7.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDEx8ghuZR61pqPXc6da9C
This commit is contained in:
2026-07-17 00:32:19 -04:00
parent 5fc65d6fb3
commit cdc5d1528c
14 changed files with 376 additions and 406 deletions
-114
View File
@@ -1,114 +0,0 @@
'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};
+50 -5
View File
@@ -103,7 +103,6 @@ async function addPosixAccount(client, data){
givenName: data.givenName,
loginShell: data.loginShell,
homeDirectory: data.homeDirectory,
userPassword: data.userPassword,
description: data.description || ' ',
sudoHost: 'ALL',
sudoCommand: 'ALL',
@@ -131,6 +130,19 @@ async function addPosixAccount(client, data){
entry.dateOfBirth = data.dob;
}
// userPassword is optional -- a service account with no password set
// simply can't bind (no special enforcement needed, that's the default
// LDAP simple-bind behavior for an entry lacking the attribute).
if (data.userPassword) {
entry.userPassword = data.userPassword;
}
// manager (COSINE, SUP distinguishedName) is naturally multi-valued --
// every account gets at least the DN of whoever created it.
if (data.manager && [].concat(data.manager).length) {
entry.manager = [].concat(data.manager);
}
await client.add(`cn=${data.cn},${conf.userBase}`, entry);
return data
@@ -151,9 +163,13 @@ async function addLdapUser(client, data){
data.uid = `${data.givenName[0]}${data.sn}`.toLowerCase();
}
data.cn = data.uid;
data.loginShell = '/bin/bash';
data.homeDirectory= `/home/${data.uid}`;
data.userPassword = hashPasswordSSHA512(data.userPassword);
data.loginShell = data.loginShell || '/bin/bash';
data.homeDirectory = data.homeDirectory || `/home/${data.uid}`;
if (data.userPassword) {
data.userPassword = hashPasswordSSHA512(data.userPassword);
} else {
delete data.userPassword;
}
console.log('addLdapUser', data)
group = await addPosixGroup(client, data);
@@ -194,6 +210,11 @@ const user_parse = function(data){
data.isActive = data.pwdAccountLockedTime ? '' : 'active';
data.isInactive = data.pwdAccountLockedTime ? 'inactive' : '';
// manager (COSINE, SUP distinguishedName) is multi-valued; ldapts returns
// a bare string for a single value and an array for multiple -- normalize
// to always be an array of DNs.
data.manager = [].concat(data.manager || []).filter(Boolean);
return data;
}
@@ -242,6 +263,8 @@ User.listDetail = async function(){
serviceAccountDNs = new Set((svcGroup.member || []).map(dn => dn.toLowerCase()));
}catch(error){ /* group not seeded yet on an old deployment -- treat as none */ }
const dnToUid = new Map(searchEntries.map(e => [String(e.dn).toLowerCase(), e.uid]));
const users = await Promise.all(searchEntries.map(async (entry) => {
const rawPassword = entry.userPassword ? entry.userPassword.toString() : '';
const isLegacyMD5 = rawPassword.toUpperCase().startsWith('{MD5}');
@@ -269,6 +292,7 @@ User.listDetail = async function(){
].filter(Boolean);
obj.onboardingRequired = obj.onboardingNeeds.length > 0 ? 'yes' : '';
obj.isServiceAccount = serviceAccountDNs.has(String(obj.dn).toLowerCase()) ? 'yes' : '';
obj.managerUids = obj.manager.map(dn => dnToUid.get(String(dn).toLowerCase()) || dn);
return obj;
}));
@@ -421,7 +445,7 @@ User.update = async function(data){
}
}
let editableFeilds = ['mobile', 'description'];
let editableFeilds = ['mobile', 'description', 'homeDirectory', 'loginShell'];
await withClient(async (client) => {
for(let field of editableFeilds){
@@ -469,6 +493,21 @@ User.update = async function(data){
]);
this.dateOfBirth = data.dateOfBirth;
}
if(data.manager !== undefined){
// Client sends uids; resolve each to a DN before writing --
// manager (COSINE, SUP distinguishedName) stores DNs, not uids.
const uids = [].concat(data.manager || []).filter(Boolean);
const managers = await Promise.all(uids.map(uid => User.get(uid)));
const dns = managers.map(u => u.dn);
await client.modify(this.dn, [
new Change({
operation: 'replace',
modification: new Attribute({ type: 'manager', values: dns }),
}),
]);
this.manager = dns;
}
});
cache.clear();
@@ -537,6 +576,12 @@ User.addByInvite = async function(data){
data.mail = token.mail;
// Default manager: whoever sent the invite.
try {
const inviter = await this.get(token.created_by);
data.manager = [inviter.dn];
} catch(e) { /* inviter no longer exists -- leave manager unset */ }
const suggestions = await this.usernameSuggestions(data.givenName, data.sn, data.dob);
if (!data.uid || !suggestions.includes(data.uid)) {
const err = new Error('Invalid username selection');