Compare commits

...

4 Commits

Author SHA1 Message Date
wmantly 96adf60cf7 Merge pull request #78 from theta42/personal-group-members
Add personal Unix group member management
2026-07-17 11:31:11 -04:00
wmantly 82f703f560 Add personal Unix group member management
Every account gets a personal posixGroup at creation (its primary GID
holder) but there was no way to manage its memberUid list -- add
add/remove endpoints and a profile-page UI (admin-only), reusing the
userSelect widget already built for the manager field.

Bumps to v1.1.9.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDEx8ghuZR61pqPXc6da9C
2026-07-17 11:28:41 -04:00
wmantly 65b107d8ff Merge pull request #77 from theta42/fix-account-editing-bugs
Fix account-editing bugs; add editable group membership from profile
2026-07-17 11:03:05 -04:00
wmantly 5d7c0bd594 Fix account-editing bugs from real-world feedback, add editable group membership
- Edit form's Mobile Phone field was effectively required (stray validate
  attribute) -- removed.
- Service account profiles always showed the literal filler name "Service
  Account" -- hidden now, since it's not meaningful. Required computing
  isServiceAccount in User.get(), not just listDetail().
- Fresh service accounts could look uncategorized (missing from the
  Service Accounts tab, wrong isServiceAccount) for up to 5 minutes after
  creation, due to a cache-staleness race in the create route -- the user
  gets cached via User.get() before the route marks it as a service
  account. Cleared and re-fetched after marking.
- memberOf came back as a bare string instead of a one-element array for
  users in exactly one group, causing client-side permission checks to
  iterate character-by-character and incorrectly deny access -- normalized
  alongside the existing manager normalization.
- Added editable group membership on the profile page ("My groups"),
  admin-only, using the existing per-group member endpoints.

Bumps to v1.1.8.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDEx8ghuZR61pqPXc6da9C
2026-07-17 11:00:50 -04:00
7 changed files with 273 additions and 12 deletions
+19 -1
View File
@@ -6,6 +6,22 @@ correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
## [Unreleased] ## [Unreleased]
## [1.1.9] - 2026-07-17
### Added
- Every account's personal Unix group (its primary GID holder) can now have supplementary members managed from the account's profile page ("Members of `<uid>`'s group", admin-only) — e.g. to share write access to files owned by that group. Uses the standard `memberUid` attribute (RFC 2307 `posixGroup`).
## [1.1.8] - 2026-07-17
### Added
- Group membership is now editable directly from a user's profile page ("My groups" -- add via a group-name picker, remove with a button per row), instead of only from each group's own card on the Groups page. Admin-only, using the existing per-group member add/remove endpoints.
### Fixed
- The Edit Profile form's Mobile Phone field had a stray `validate=":9"` making it effectively required (submission was blocked with "Please fix the form errors" if left blank) -- it was always meant to be optional, matching the "Add user" form. Removed.
- A service account's profile always showed `Name: Service Account` -- every service account has the same literal filler given/last name (a schema-satisfying placeholder, not meant to be shown), making them indistinguishable by name. The Name line is now hidden for service accounts.
- The Users page's Service Accounts tab, and a freshly-created service account's own profile, could appear empty/not-a-service-account for up to 5 minutes right after creation. Creating a user caches it via `User.get()` *before* the route handler marks it as a service account (group membership), so the cached copy had `isServiceAccount` stuck wrong until the cache TTL expired. Now cleared and re-fetched immediately after marking.
- A user belonging to exactly one LDAP group had their `memberOf` attribute returned as a bare string instead of a one-element array (ldapts's normal behavior for single-valued attributes) -- client-side permission checks (`for(let group of user.memberOf)`) would then iterate the DN character-by-character instead of once, causing pages gated on that group (e.g. Groups) to incorrectly show "You do not have permission to be here." Normalized `memberOf` to always be an array, same fix already applied to `manager`.
## [1.1.7] - 2026-07-17 ## [1.1.7] - 2026-07-17
### Changed ### Changed
@@ -61,7 +77,9 @@ First tagged release. Establishes the `vX.Y.Z` tag convention that the in-app up
- Unix/POSIX and LDAP bind-only service account support, distinct from real-person accounts. - Unix/POSIX and LDAP bind-only service account support, distinct from real-person accounts.
- Merged OAuth Apps + LDAP Info into a single Integrations page. - Merged OAuth Apps + LDAP Info into a single Integrations page.
[Unreleased]: https://github.com/theta42/sso-manager-node/compare/v1.1.7...HEAD [Unreleased]: https://github.com/theta42/sso-manager-node/compare/v1.1.9...HEAD
[1.1.9]: https://github.com/theta42/sso-manager-node/compare/v1.1.8...v1.1.9
[1.1.8]: https://github.com/theta42/sso-manager-node/compare/v1.1.7...v1.1.8
[1.1.7]: https://github.com/theta42/sso-manager-node/compare/v1.1.6...v1.1.7 [1.1.7]: https://github.com/theta42/sso-manager-node/compare/v1.1.6...v1.1.7
[1.1.6]: https://github.com/theta42/sso-manager-node/compare/v1.1.5...v1.1.6 [1.1.6]: https://github.com/theta42/sso-manager-node/compare/v1.1.5...v1.1.6
[1.1.5]: https://github.com/theta42/sso-manager-node/compare/v1.1.4...v1.1.5 [1.1.5]: https://github.com/theta42/sso-manager-node/compare/v1.1.4...v1.1.5
+13
View File
@@ -57,6 +57,19 @@ membership (`memberOf` on the user); `refint` keeps it consistent on
add/remove. **Admin permission checks read the group's `member` list**, not add/remove. **Admin permission checks read the group's `member` list**, not
`memberOf` on the user. `memberOf` on the user.
### Personal groups
Every user (person or service account) also gets a **personal Unix group**
at creation — `cn=<uid>,ou=groups,<base>`, `objectClass: posixGroup` (RFC
2307), holding just `cn` and `gidNumber` (the user's primary GID). This is a
different schema than the `groupOfNames` groups above — its membership
attribute is `memberUid` (a bare username, not a DN), and unlike
`groupOfNames` it's valid with zero members. It's excluded from the
`/groups` page (which filters on `objectClass=groupOfNames`) and managed
instead from the owning user's own profile page ("Members of `<uid>`'s
group", admin-only) — add other accounts as supplementary members, e.g. to
share write access to files owned by this group.
The SSO requires three groups (seeded automatically by the entrypoint / The SSO requires three groups (seeded automatically by the entrypoint /
`install.sh`): `install.sh`):
+61 -4
View File
@@ -210,10 +210,13 @@ const user_parse = function(data){
data.isActive = data.pwdAccountLockedTime ? '' : 'active'; data.isActive = data.pwdAccountLockedTime ? '' : 'active';
data.isInactive = data.pwdAccountLockedTime ? 'inactive' : ''; data.isInactive = data.pwdAccountLockedTime ? 'inactive' : '';
// manager (COSINE, SUP distinguishedName) is multi-valued; ldapts returns // manager (COSINE, SUP distinguishedName) and memberOf (from the memberof
// a bare string for a single value and an array for multiple -- normalize // overlay) are both multi-valued; ldapts returns a bare string for a
// to always be an array of DNs. // single value and an array for multiple -- normalize both to always be
data.manager = [].concat(data.manager || []).filter(Boolean); // an array, or app-base.js's `for(let group of user.memberOf)` silently
// iterates a single DN string character-by-character instead of once.
data.manager = [].concat(data.manager || []).filter(Boolean);
data.memberOf = [].concat(data.memberOf || []).filter(Boolean);
return data; return data;
} }
@@ -348,6 +351,13 @@ User.get = async function(data, key) {
const verif = await UserVerification.getOrCreate(obj.uid); const verif = await UserVerification.getOrCreate(obj.uid);
// Same membership check as User.listDetail() -- see the comment there.
try{
const svcGroup = await Group.get('app_sso_service_account');
const serviceAccountDNs = new Set((svcGroup.member || []).map(dn => dn.toLowerCase()));
obj.isServiceAccount = serviceAccountDNs.has(String(obj.dn).toLowerCase()) ? 'yes' : '';
}catch(error){ obj.isServiceAccount = ''; }
// Auto-flag legacy MD5 password users — persist so subsequent cache hits see it // Auto-flag legacy MD5 password users — persist so subsequent cache hits see it
if (isLegacyMD5 && !verif.password_must_change) { if (isLegacyMD5 && !verif.password_must_change) {
await verif.update({ password_must_change: true }); await verif.update({ password_must_change: true });
@@ -784,6 +794,53 @@ User.addSSHkey = async function(data) {
return result; return result;
}; };
// Every user gets a personal Unix group of the same name at creation (see
// addPosixGroup) -- just a GID holder, cn always equal to the user's uid.
// memberUid (RFC 2307, posixGroup) is a bare username, not a DN, unlike
// groupOfNames' `member` used by app_sso_* groups in group_ldap.js.
function personalGroupDN(uid){
return `cn=${uid},${conf.groupBase}`;
}
User.getPersonalGroupMembers = async function(uid) {
try {
return await withClient(async (client) => {
const res = await client.search(personalGroupDN(uid), {
scope: 'base',
filter: '(objectClass=posixGroup)',
attributes: ['memberUid'],
});
const entry = res.searchEntries[0];
return [].concat((entry && entry.memberUid) || []).filter(Boolean);
});
} catch(error) {
throw error;
}
};
User.addPersonalGroupMember = async function(uid, memberUid) {
await this.get(memberUid); // throws UserNotFound if the target uid doesn't exist
await withClient(async (client) => {
await client.modify(personalGroupDN(uid), [
new Change({
operation: 'add',
modification: new Attribute({ type: 'memberUid', values: [memberUid] }),
}),
]);
});
};
User.removePersonalGroupMember = async function(uid, memberUid) {
await withClient(async (client) => {
await client.modify(personalGroupDN(uid), [
new Change({
operation: 'delete',
modification: new Attribute({ type: 'memberUid', values: [memberUid] }),
}),
]);
});
};
User.invite = async function(data = {}){ User.invite = async function(data = {}){
try{ try{
let token = await InviteToken.create({ let token = await InviteToken.create({
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "t42-sso-manager", "name": "t42-sso-manager",
"version": "1.1.7", "version": "1.1.9",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "t42-sso-manager", "name": "t42-sso-manager",
"version": "1.1.7", "version": "1.1.9",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-free": "^7.3.0", "@fortawesome/fontawesome-free": "^7.3.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "t42-sso-manager", "name": "t42-sso-manager",
"version": "1.1.7", "version": "1.1.9",
"private": true, "private": true,
"author": [ "author": [
{ {
+42 -1
View File
@@ -25,7 +25,7 @@ router.post('/', async function(req, res, next){
req.body.created_by = req.user.uid req.body.created_by = req.user.uid
req.body.manager = [req.user.dn]; req.body.manager = [req.user.dn];
const user = await User.add(req.body); let user = await User.add(req.body);
const verif = await UserVerification.getOrCreate(user.uid); const verif = await UserVerification.getOrCreate(user.uid);
const updates = { password_must_change: true }; const updates = { password_must_change: true };
if (req.body.tosAgree) updates.tos_accepted = true, updates.tos_accepted_at = Date.now(); if (req.body.tosAgree) updates.tos_accepted = true, updates.tos_accepted_at = Date.now();
@@ -38,6 +38,12 @@ router.post('/', async function(req, res, next){
try { try {
const group = await Group.get('app_sso_service_account'); const group = await Group.get('app_sso_service_account');
await group.addMember(user); await group.addMember(user);
// User.add() already cached `user` (via its own internal
// User.get()) before this group membership existed, so the
// cached isServiceAccount would be stuck wrong for 5 minutes
// (the cache TTL) without this -- re-fetch after clearing.
User.clearCache();
user = await User.get(user.uid);
} catch (error) { } catch (error) {
console.error(`user.add: failed to mark ${user.uid} as a service account:`, error.message); console.error(`user.add: failed to mark ${user.uid} as a service account:`, error.message);
} }
@@ -138,6 +144,41 @@ router.put('/:uid/active', async function(req, res, next){
} }
}); });
router.get('/:uid/group-members', async function(req, res, next){
try{
await permission.byGroup(req.user, ['app_sso_admin']);
return res.json({results: await User.getPersonalGroupMembers(req.params.uid)});
}catch(error){
next(error);
}
});
router.put('/:uid/group-member/:memberUid', async function(req, res, next){
try{
await permission.byGroup(req.user, ['app_sso_admin']);
await User.addPersonalGroupMember(req.params.uid, req.params.memberUid);
return res.json({
results: true,
message: `Added ${req.params.memberUid} to ${req.params.uid}'s group`
});
}catch(error){
next(error);
}
});
router.delete('/:uid/group-member/:memberUid', async function(req, res, next){
try{
await permission.byGroup(req.user, ['app_sso_admin']);
await User.removePersonalGroupMember(req.params.uid, req.params.memberUid);
return res.json({
results: true,
message: `Removed ${req.params.memberUid} from ${req.params.uid}'s group`
});
}catch(error){
next(error);
}
});
router.put('/:uid', async function(req, res, next){ router.put('/:uid', async function(req, res, next){
try{ try{
let user; let user;
+135 -3
View File
@@ -18,12 +18,77 @@
async function renderUserGroups(user){ async function renderUserGroups(user){
try{ try{
let res = await app.api.get('group/?detail=true&member='+user.uid); let res = await app.api.get('group/?detail=true&member='+user.uid);
$.scope.mygroups.empty();
$.scope.mygroups.push(...res.results); $.scope.mygroups.push(...res.results);
}catch(error){ }catch(error){
console.error('renderUserGroups error:', error) console.error('renderUserGroups error:', error)
} }
} }
async function removeFromGroup(cn, btn){
const $row = $(btn).closest('tr');
const confirmed = await app.util.actionConfirm(`Remove ${currentUser.uid} from "${cn}"?`, $row, 'warning');
if (!confirmed) return;
app.api.delete('group/' + encodeURIComponent(cn) + '/' + encodeURIComponent(currentUser.uid), function(error, data){
if(error){ app.util.actionMessage((data && data.message) || 'Failed to remove from group', $row, 'danger'); return; }
$.scope.mygroups.remove('cn', cn);
});
}
var addGroupSelect;
async function addToGroups(btn){
const cns = addGroupSelect.get();
if(!cns.length) return;
const $card = $(btn).closest('.card-body');
for(const cn of cns){
await new Promise(function(resolve){
app.api.put('group/' + encodeURIComponent(cn) + '/' + encodeURIComponent(currentUser.uid), {}, function(error, data){
if(error) app.util.actionMessage((data && data.message) || `Failed to add to "${cn}"`, $card, 'danger');
resolve();
});
});
}
addGroupSelect.clear();
renderUserGroups(currentUser);
}
async function renderPersonalGroupMembers(user){
try{
let res = await app.api.get('user/' + user.uid + '/group-members');
$.scope.personalGroupMembers.empty();
$.scope.personalGroupMembers.push(...(res.results || []).map(uid => ({uid})));
}catch(error){
console.error('renderPersonalGroupMembers error:', error)
}
}
async function removePersonalGroupMember(memberUid, btn){
const $row = $(btn).closest('tr');
const confirmed = await app.util.actionConfirm(`Remove ${memberUid} from ${currentUser.uid}'s group?`, $row, 'warning');
if (!confirmed) return;
app.api.delete('user/' + encodeURIComponent(currentUser.uid) + '/group-member/' + encodeURIComponent(memberUid), function(error, data){
if(error){ app.util.actionMessage((data && data.message) || 'Failed to remove from group', $row, 'danger'); return; }
$.scope.personalGroupMembers.remove('uid', memberUid);
});
}
var addPersonalGroupMemberSelect;
async function addPersonalGroupMembers(btn){
const uids = addPersonalGroupMemberSelect.get();
if(!uids.length) return;
const $card = $(btn).closest('.card-body');
for(const uid of uids){
await new Promise(function(resolve){
app.api.put('user/' + encodeURIComponent(currentUser.uid) + '/group-member/' + encodeURIComponent(uid), {}, function(error, data){
if(error) app.util.actionMessage((data && data.message) || `Failed to add "${uid}"`, $card, 'danger');
resolve();
});
});
}
addPersonalGroupMemberSelect.clear();
renderPersonalGroupMembers(currentUser);
}
async function determinUser(){ async function determinUser(){
if(location.pathname.includes('/users/')){ if(location.pathname.includes('/users/')){
let uid = location.pathname.replace('/users/', ''); let uid = location.pathname.replace('/users/', '');
@@ -94,6 +159,15 @@
renderProfile(currentUser); renderProfile(currentUser);
renderUserGroups(currentUser); renderUserGroups(currentUser);
renderPersonalGroupMembers(currentUser);
$('#personal-group-uid-label').text(currentUser.uid);
addGroupSelect = app.ui.groupSelect('#add-group-select', {
name: 'groups', values: [], placeholder: 'Type a group name…',
});
addPersonalGroupMemberSelect = app.ui.userSelect('#add-personal-group-member-select', {
name: 'members', values: [], placeholder: 'Type a username…',
});
// API Tokens are self-service only — never shown when an admin is // API Tokens are self-service only — never shown when an admin is
// viewing someone else's profile via /users/:uid. // viewing someone else's profile via /users/:uid.
@@ -159,7 +233,7 @@
<div class="profile-body" jq-repeat="user"> <div class="profile-body" jq-repeat="user">
<div class="card-body profile-body-{{uid}}"> <div class="card-body profile-body-{{uid}}">
<h2><i>User Name:</i> <b>{{uid}}</b></h2> <h2><i>User Name:</i> <b>{{uid}}</b></h2>
<i>Name:</i> <b>{{givenName}} {{sn}}</b><br /> {{^isServiceAccount}}<i>Name:</i> <b>{{givenName}} {{sn}}</b><br />{{/isServiceAccount}}
<i>Email:</i> <b>{{mail}}</b> <i>Email:</i> <b>{{mail}}</b>
{{#emailVerified}}<span class="badge bg-success ms-1"><i class="fa-solid fa-circle-check"></i> Verified</span>{{/emailVerified}} {{#emailVerified}}<span class="badge bg-success ms-1"><i class="fa-solid fa-circle-check"></i> Verified</span>{{/emailVerified}}
<br /> <br />
@@ -247,7 +321,7 @@
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Mobile Phone</label> <label class="form-label">Mobile Phone</label>
<input type="text" class="form-control" name="mobile" placeholder="9175551234" validate=":9" value="{{mobile}}" /> <input type="text" class="form-control" name="mobile" placeholder="9175551234" value="{{mobile}}" />
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Home Directory</label> <label class="form-label">Home Directory</label>
@@ -282,7 +356,7 @@
</div> </div>
<div class="card-header shadow actionMessage" style="display:none"> <div class="card-header shadow actionMessage" style="display:none">
</div> </div>
<div class="card-body" style="padding-bottom:0"> <div class="card-body">
<div class="table-responsive"> <div class="table-responsive">
<table class="table"> <table class="table">
<thead> <thead>
@@ -292,19 +366,77 @@
<th> <th>
Description Description
</th> </th>
<th class="group-required group-required-app_sso_admin"></th>
</thead> </thead>
<tbody jq-repeat="mygroups"> <tbody jq-repeat="mygroups">
<tr> <tr>
<td>{{cn}}</td> <td>{{cn}}</td>
<td>{{description}}</td> <td>{{description}}</td>
<td class="text-end group-required group-required-app_sso_admin">
<button type="button" class="btn btn-sm btn-outline-danger" title="Remove from group" onclick="removeFromGroup('{{cn}}', this)">
<i class="fa-solid fa-xmark"></i>
</button>
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="group-required group-required-app_sso_admin">
<label class="form-label small">Add to group</label>
<div class="d-flex gap-2 align-items-start">
<div id="add-group-select" class="flex-grow-1"></div>
<button type="button" class="btn btn-outline-dark" onclick="addToGroups(this)">Add</button>
</div>
</div>
</div>
</div>
<div class="shadow-lg card card-default mb-8 group-required group-required-app_sso_admin">
<div class="card-header shadow">
<i class="fa-solid fa-people-group"></i>
Members of <span id="personal-group-uid-label"></span>'s group
<div class="float-end">
<i class="fa-solid fa-arrows-up-down"></i>
</div>
</div>
<div class="card-header shadow actionMessage" style="display:none">
</div>
<div class="card-body">
<p class="text-muted small">
Every account gets a personal Unix group (its primary GID) — add
other accounts here as supplementary members (e.g. to share write
access to files owned by this group).
</p>
<div class="table-responsive">
<table class="table">
<thead>
<th>
Username
</th>
<th class="text-end"></th>
</thead>
<tbody jq-repeat="personalGroupMembers">
<tr>
<td>{{uid}}</td>
<td class="text-end">
<button type="button" class="btn btn-sm btn-outline-danger" title="Remove from group" onclick="removePersonalGroupMember('{{uid}}', this)">
<i class="fa-solid fa-xmark"></i>
</button>
</td>
</tr>
</tbody>
</table>
</div>
<label class="form-label small">Add member</label>
<div class="d-flex gap-2 align-items-start">
<div id="add-personal-group-member-select" class="flex-grow-1"></div>
<button type="button" class="btn btn-outline-dark" onclick="addPersonalGroupMembers(this)">Add</button>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- Token modal (shown once on create/rotate) --> <!-- Token modal (shown once on create/rotate) -->
<div class="modal fade" id="secretModal" tabindex="-1"> <div class="modal fade" id="secretModal" tabindex="-1">