diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 0b350fc..d59caf0 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -105,7 +105,8 @@ bare-metal / advanced standalone use; most deployments should use the file. listening on `ldap:///` (389) and `ldaps:///` (636). 3. Seeds the directory (base DN, `ou=people`/`ou=groups`/`ou=policies`, a default `pwdPolicy`, and the required SSO groups `app_sso_admin`, `app_sso_invite`, - `app_sso_oauth_admin`) — idempotently, so container restarts are safe. + `app_sso_oauth_admin`, `app_sso_service_account`) — idempotently, so + container restarts are safe. 4. Starts a bundled Redis (the app uses `model-redis` for models/sessions and stores OAuth clients there), AOF+RDB persisted to `/data`, unless `app_redis__host` is set (then it's expected to be external). @@ -388,7 +389,9 @@ The app needs these on the LDAP server: - **Directory tree:** `ou=people`, `ou=groups`, `ou=policies` under the base DN, a default `pwdPolicy` at `cn=ppolicy,ou=policies,`. - **Required groups:** `app_sso_admin` (full admin), `app_sso_invite` (invitation - management), `app_sso_oauth_admin` (OAuth client management). + management), `app_sso_oauth_admin` (OAuth client management), + `app_sso_service_account` (not a permission — marks a `posixAccount` as a + non-person service account; see docs/ldap.md). `ops/ldap-setup.sh -p ` configures all of the above idempotently against a running slapd (auto-detects the database holding your base DN, and diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 71ba248..40614ba 100755 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -272,8 +272,10 @@ pwdMustChange: FALSE pwdAllowUserChange: TRUE EOF - # Required SSO groups. The app gates admin/invite/oauth-admin on these. - for group in app_sso_admin app_sso_invite app_sso_oauth_admin; do + # Required SSO groups. The app gates admin/invite/oauth-admin on these; + # app_sso_service_account is a marker (not a permission gate) for + # non-person accounts -- see the Users page. + for group in app_sso_admin app_sso_invite app_sso_oauth_admin app_sso_service_account; do ldapadd -x -D "$LDAP_BIND_DN" -w "$LDAP_ADMIN_PASS" -H ldap://localhost:389 << EOF || true dn: cn=${group},ou=groups,${LDAP_BASE_DN} objectClass: groupOfNames diff --git a/docs/ldap.md b/docs/ldap.md index 07bbdd9..0b56ef7 100644 --- a/docs/ldap.md +++ b/docs/ldap.md @@ -55,6 +55,7 @@ The SSO requires three groups (seeded automatically by the entrypoint / | `app_sso_admin` | full admin (users, groups, settings) | | `app_sso_oauth_admin` | OAuth client management | | `app_sso_invite` | invitation management | +| `app_sso_service_account` | not a permission — marks a `posixAccount` as a non-person service account (see *Service accounts* below) | ## TLS (LDAPS / StartTLS) @@ -90,17 +91,35 @@ volumes: The entrypoint leaves existing certs untouched (idempotent). -## Direct-bind service accounts +## Service accounts -For apps that bind LDAP directly, create a dedicated **service account** under -`ou=people` (e.g. `cn=ldapclient,ou=people,`) with a strong password — -**don't reuse the admin DN**. The theta-env bootstrap creates this account -automatically (`cn=ldapclient`) and the proxy binds as it. For anything else, -create a normal user via the Users page (or `POST /api/user`) and just don't -put it in `app_sso_admin` or any other privileged group — a plain -`posixAccount` with a strong password is all a read-only bind account needs. +There are two different kinds of "not a real person" account, and which one +you want depends on what's consuming it: -Example bind test: +**LDAP bind-only** — for an app that just needs to bind LDAP to look users up +(its own "LDAP authentication" settings page, or the read-only account +`theta42/ldap-client` binds as). Not a `posixAccount` — no `uidNumber`, no +home directory, can't log into this UI. Create one from the +**Integrations → LDAP** tab's *Service Accounts* section (create, rotate +password, delete). theta-env's bootstrap creates `cn=ldapclient` this same +way automatically, and the proxy binds as it — don't reuse the admin DN for +this. + +**Unix/POSIX** — for an account something actually *runs as* on a Linux +host: a media manager, a torrent client, a service like Emby — anything that +needs a real `uidNumber`/`gidNumber` to own files or that other accounts join +via a group for write access (e.g. a `stuff_manager` group granting write +rights to a media library). Create one from the **Users** page's "Add new +user" form with **This is a service account** checked — it skips the +birthday/Terms-of-Service fields a real person's account needs and asks for +just an account name. It's a normal `posixAccount`, just flagged (via +membership in the `app_sso_service_account` group) so it's visibly marked in +the Users list and excluded from "all users" notification broadcasts. + +Either way: don't reuse the admin DN, and give it only the group memberships +it actually needs. + +Example bind test (LDAP bind-only account): ```bash ldapsearch -x -H ldaps://sso.example.com:636 \ diff --git a/nodejs/models/user_ldap.js b/nodejs/models/user_ldap.js index 988b403..0d43baa 100644 --- a/nodejs/models/user_ldap.js +++ b/nodejs/models/user_ldap.js @@ -101,7 +101,6 @@ async function addPosixAccount(client, data){ uidNumber: data.uidNumber, gidNumber: data.gidNumber, givenName: data.givenName, - mail: data.mail, loginShell: data.loginShell, homeDirectory: data.homeDirectory, userPassword: data.userPassword, @@ -112,6 +111,14 @@ async function addPosixAccount(client, data){ objectclass: ['inetOrgPerson', 'sudoRole', 'ldapPublicKey', 'posixAccount', 'top', 'theta42Person'], }; + // mail is optional in the inetOrgPerson schema, but ldapts/slapd reject an + // attribute given an explicit undefined value ("no values for attribute + // type") rather than just omitting it -- service accounts (a Unix account + // an app/service runs as) commonly have no real mailbox. + if (data.mail) { + entry.mail = data.mail; + } + if (data.mobile) { entry.mobile = data.mobile; } @@ -225,6 +232,16 @@ User.listDetail = async function(){ return res.searchEntries; }); + // Members of app_sso_service_account are non-person accounts (media + // managers, app service users, ...) -- fetched once here rather than + // relying on the memberof overlay's reverse attribute, which isn't + // reliably returned by every LDAP server this app might point at. + let serviceAccountDNs = new Set(); + try{ + const svcGroup = await Group.get('app_sso_service_account'); + serviceAccountDNs = new Set((svcGroup.member || []).map(dn => dn.toLowerCase())); + }catch(error){ /* group not seeded yet on an old deployment -- treat as none */ } + const users = await Promise.all(searchEntries.map(async (entry) => { const rawPassword = entry.userPassword ? entry.userPassword.toString() : ''; const isLegacyMD5 = rawPassword.toUpperCase().startsWith('{MD5}'); @@ -251,6 +268,7 @@ User.listDetail = async function(){ passwordMustChange && 'password', ].filter(Boolean); obj.onboardingRequired = obj.onboardingNeeds.length > 0 ? 'yes' : ''; + obj.isServiceAccount = serviceAccountDNs.has(String(obj.dn).toLowerCase()) ? 'yes' : ''; return obj; })); diff --git a/nodejs/routes/notification.js b/nodejs/routes/notification.js index 4bcbcc9..4bfcc09 100644 --- a/nodejs/routes/notification.js +++ b/nodejs/routes/notification.js @@ -9,7 +9,11 @@ const permission = require('../utils/permission'); async function resolveRecipients(filter_type, filter_value, active_only) { if (filter_type === 'all' || filter_type === 'all_active') { - const users = await User.listDetail(); + // Service accounts (media managers, app service users, ...) aren't + // read by anyone -- a broadcast to "all users" shouldn't include them. + // Target one explicitly via filter_type=users/group if it ever needs + // its own notification. + const users = (await User.listDetail()).filter(u => !u.isServiceAccount); const active = filter_type === 'all_active' || active_only; return active ? users.filter(u => !u.pwdAccountLockedTime) : users; } diff --git a/nodejs/routes/user.js b/nodejs/routes/user.js index cc4a99a..0bf8fbb 100755 --- a/nodejs/routes/user.js +++ b/nodejs/routes/user.js @@ -29,6 +29,19 @@ router.post('/', async function(req, res, next){ const updates = { password_must_change: true }; if (req.body.tosAgree) updates.tos_accepted = true, updates.tos_accepted_at = Date.now(); await verif.update(updates); + + // Service accounts (a Unix account an app/service runs as, not a + // person) are marked by membership in app_sso_service_account rather + // than a schema change -- see models/user_ldap.js User.listDetail. + if (req.body.isServiceAccount === true || req.body.isServiceAccount === 'true' || req.body.isServiceAccount === 'on') { + try { + const group = await Group.get('app_sso_service_account'); + await group.addMember(user); + } catch (error) { + console.error(`user.add: failed to mark ${user.uid} as a service account:`, error.message); + } + } + return res.json({results: user}); }catch(error){ next(error); diff --git a/nodejs/views/integrations.ejs b/nodejs/views/integrations.ejs index 3c47a6f..e850e7e 100644 --- a/nodejs/views/integrations.ejs +++ b/nodejs/views/integrations.ejs @@ -552,6 +552,12 @@ into this UI, no home directory. theta-env's cn=ldapclient bootstrap account (used by theta42/proxy) shows up here too, since it's the same kind of account. +
+ Need an account something actually runs as on a Linux host instead + (a media manager, a torrent client, ...) — with a real uidNumber + and a group other accounts join for write access? That's a Unix account, not + a bind-only one — create it from Users with + This is a service account checked.

diff --git a/nodejs/views/user_form.ejs b/nodejs/views/user_form.ejs index a969827..1b29a60 100644 --- a/nodejs/views/user_form.ejs +++ b/nodejs/views/user_form.ejs @@ -43,17 +43,63 @@ async function fetchUsernameSuggestions() { } catch(e) {} } +<% if (locals.adminMode) { %> + +<% } %>
-
- - -
+ <% if (locals.adminMode) { %> +
+ +
+ + <% } %> +
+
+ + +
+
-
- - +
+ + +
diff --git a/nodejs/views/users.ejs b/nodejs/views/users.ejs index b07afb1..31a7bcd 100755 --- a/nodejs/views/users.ejs +++ b/nodejs/views/users.ejs @@ -163,6 +163,7 @@ {{givenName}} {{sn}} + {{#isServiceAccount}} service{{/isServiceAccount}} {{mail}} diff --git a/ops/ldap-setup.sh b/ops/ldap-setup.sh index b01173c..a31a682 100755 --- a/ops/ldap-setup.sh +++ b/ops/ldap-setup.sh @@ -244,7 +244,7 @@ fi # ── 8. Required SSO groups ──────────────────────────────────────────────────── info "required SSO groups" -for group in app_sso_admin app_sso_invite app_sso_oauth_admin; do +for group in app_sso_admin app_sso_invite app_sso_oauth_admin app_sso_service_account; do dn="cn=${group},${GROUP_BASE}" if dir_search -b "$dn" -s base "(objectClass=*)" dn 2>/dev/null | grep -q "dn:"; then skip "${dn} already exists"