diff --git a/CHANGELOG.md b/CHANGELOG.md index 66faf4b..c32a170 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`. ## [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 ``'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 @@ -72,7 +77,8 @@ 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. - Merged OAuth Apps + LDAP Info into a single Integrations page. -[Unreleased]: https://github.com/theta42/sso-manager-node/compare/v1.1.8...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.6]: https://github.com/theta42/sso-manager-node/compare/v1.1.5...v1.1.6 diff --git a/docs/ldap.md b/docs/ldap.md index a61a2e0..93a6478 100644 --- a/docs/ldap.md +++ b/docs/ldap.md @@ -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 `memberOf` on the user. +### Personal groups + +Every user (person or service account) also gets a **personal Unix group** +at creation — `cn=,ou=groups,`, `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 ``'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 / `install.sh`): diff --git a/nodejs/models/user_ldap.js b/nodejs/models/user_ldap.js index 11f9e2a..50f3e30 100644 --- a/nodejs/models/user_ldap.js +++ b/nodejs/models/user_ldap.js @@ -794,6 +794,53 @@ User.addSSHkey = async function(data) { 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 = {}){ try{ let token = await InviteToken.create({ diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index f1ecf3d..a2bc4a2 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -1,12 +1,12 @@ { "name": "t42-sso-manager", - "version": "1.1.8", + "version": "1.1.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "t42-sso-manager", - "version": "1.1.8", + "version": "1.1.9", "license": "MIT", "dependencies": { "@fortawesome/fontawesome-free": "^7.3.0", diff --git a/nodejs/package.json b/nodejs/package.json index b50446c..8af4bc7 100755 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -1,6 +1,6 @@ { "name": "t42-sso-manager", - "version": "1.1.8", + "version": "1.1.9", "private": true, "author": [ { diff --git a/nodejs/routes/user.js b/nodejs/routes/user.js index e327fd0..73ee612 100755 --- a/nodejs/routes/user.js +++ b/nodejs/routes/user.js @@ -144,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){ try{ let user; diff --git a/nodejs/views/profile.ejs b/nodejs/views/profile.ejs index d75cbeb..66b56ea 100644 --- a/nodejs/views/profile.ejs +++ b/nodejs/views/profile.ejs @@ -52,6 +52,43 @@ 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(){ if(location.pathname.includes('/users/')){ let uid = location.pathname.replace('/users/', ''); @@ -122,10 +159,15 @@ renderProfile(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 // viewing someone else's profile via /users/:uid. @@ -348,8 +390,53 @@ + +
+
+ + Members of 's group +
+ +
+
+ +
+

+ 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). +

+
+ + + + + + + + + + + +
+ Username +
{{uid}} + +
+
+ +
+
+ +
+
+
+