Add Unix/POSIX service accounts, distinct from LDAP bind-only ones
The Integrations page's Service Accounts (bind-only, organizationalRole)
don't cover the other real use case: an account something actually runs
as on a Linux host -- a media manager, a torrent client, Emby -- with a
real uidNumber/gidNumber that owns files, and a group other accounts
join for write access (e.g. a `stuff_manager` group granting write
rights to a media library). That needs a real posixAccount, which the
bind-only model can't be.
- New well-known group `app_sso_service_account`, seeded the same way as
app_sso_admin/app_sso_invite/app_sso_oauth_admin (docker-entrypoint.sh,
ops/ldap-setup.sh). Not a permission gate -- a marker.
- "Add new user" form gets a "This is a service account" checkbox: swaps
the person-shaped fields (first/last name, birthday, ToS agreement)
for a single account-name field, since none of those make sense for a
non-person account. On create, the route adds the user to
app_sso_service_account.
- User.listDetail() annotates each user with isServiceAccount (checked
against the marker group's member list once per call, not the memberof
overlay's reverse attribute -- not reliably returned by every LDAP
server this app might point at, confirmed against a real external
directory during testing). Users page shows a "service" badge.
- Notification broadcasts (filter_type=all/all_active) exclude service
accounts by default -- nobody reads mail as `stuff_manager`.
- Fixed a real, previously-unrelated bug this surfaced: addPosixAccount
unconditionally set `mail: data.mail` in the LDAP entry even when
undefined, and ldapts/slapd reject an attribute given an explicit
undefined value ("no values for attribute type") rather than treating
it as absent. This meant creating ANY user without an email already
failed outright -- not something a service account (which commonly has
no real mailbox) could route around. Made mail conditional, matching
how mobile/sshPublicKey/dob already work.
- docs/ldap.md now explains both kinds of service account side by side
and when to use which.
Verified end-to-end against a real external LDAP server (not a local
sandbox): created a service account with no email, confirmed it's
correctly flagged and excluded from broadcast recipient resolution,
confirmed a normal user is unaffected, confirmed the code degrades
gracefully if the marker group doesn't exist yet (pre-upgrade
deployments).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+5
-2
@@ -105,7 +105,8 @@ bare-metal / advanced standalone use; most deployments should use the file.
|
|||||||
listening on `ldap:///` (389) and `ldaps:///` (636).
|
listening on `ldap:///` (389) and `ldaps:///` (636).
|
||||||
3. Seeds the directory (base DN, `ou=people`/`ou=groups`/`ou=policies`, a default
|
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`,
|
`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
|
4. Starts a bundled Redis (the app uses `model-redis` for models/sessions and
|
||||||
stores OAuth clients there), AOF+RDB persisted to `/data`, unless
|
stores OAuth clients there), AOF+RDB persisted to `/data`, unless
|
||||||
`app_redis__host` is set (then it's expected to be external).
|
`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
|
- **Directory tree:** `ou=people`, `ou=groups`, `ou=policies` under the base DN, a
|
||||||
default `pwdPolicy` at `cn=ppolicy,ou=policies,<base>`.
|
default `pwdPolicy` at `cn=ppolicy,ou=policies,<base>`.
|
||||||
- **Required groups:** `app_sso_admin` (full admin), `app_sso_invite` (invitation
|
- **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 <admin-password>` configures all of the above idempotently
|
`ops/ldap-setup.sh -p <admin-password>` configures all of the above idempotently
|
||||||
against a running slapd (auto-detects the database holding your base DN, and
|
against a running slapd (auto-detects the database holding your base DN, and
|
||||||
|
|||||||
@@ -272,8 +272,10 @@ pwdMustChange: FALSE
|
|||||||
pwdAllowUserChange: TRUE
|
pwdAllowUserChange: TRUE
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
# Required SSO groups. The app gates admin/invite/oauth-admin on these.
|
# 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
|
# 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
|
ldapadd -x -D "$LDAP_BIND_DN" -w "$LDAP_ADMIN_PASS" -H ldap://localhost:389 << EOF || true
|
||||||
dn: cn=${group},ou=groups,${LDAP_BASE_DN}
|
dn: cn=${group},ou=groups,${LDAP_BASE_DN}
|
||||||
objectClass: groupOfNames
|
objectClass: groupOfNames
|
||||||
|
|||||||
+28
-9
@@ -55,6 +55,7 @@ The SSO requires three groups (seeded automatically by the entrypoint /
|
|||||||
| `app_sso_admin` | full admin (users, groups, settings) |
|
| `app_sso_admin` | full admin (users, groups, settings) |
|
||||||
| `app_sso_oauth_admin` | OAuth client management |
|
| `app_sso_oauth_admin` | OAuth client management |
|
||||||
| `app_sso_invite` | invitation 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)
|
## TLS (LDAPS / StartTLS)
|
||||||
|
|
||||||
@@ -90,17 +91,35 @@ volumes:
|
|||||||
|
|
||||||
The entrypoint leaves existing certs untouched (idempotent).
|
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
|
There are two different kinds of "not a real person" account, and which one
|
||||||
`ou=people` (e.g. `cn=ldapclient,ou=people,<base>`) with a strong password —
|
you want depends on what's consuming it:
|
||||||
**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.
|
|
||||||
|
|
||||||
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
|
```bash
|
||||||
ldapsearch -x -H ldaps://sso.example.com:636 \
|
ldapsearch -x -H ldaps://sso.example.com:636 \
|
||||||
|
|||||||
@@ -101,7 +101,6 @@ async function addPosixAccount(client, data){
|
|||||||
uidNumber: data.uidNumber,
|
uidNumber: data.uidNumber,
|
||||||
gidNumber: data.gidNumber,
|
gidNumber: data.gidNumber,
|
||||||
givenName: data.givenName,
|
givenName: data.givenName,
|
||||||
mail: data.mail,
|
|
||||||
loginShell: data.loginShell,
|
loginShell: data.loginShell,
|
||||||
homeDirectory: data.homeDirectory,
|
homeDirectory: data.homeDirectory,
|
||||||
userPassword: data.userPassword,
|
userPassword: data.userPassword,
|
||||||
@@ -112,6 +111,14 @@ async function addPosixAccount(client, data){
|
|||||||
objectclass: ['inetOrgPerson', 'sudoRole', 'ldapPublicKey', 'posixAccount', 'top', 'theta42Person'],
|
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) {
|
if (data.mobile) {
|
||||||
entry.mobile = data.mobile;
|
entry.mobile = data.mobile;
|
||||||
}
|
}
|
||||||
@@ -225,6 +232,16 @@ User.listDetail = async function(){
|
|||||||
return res.searchEntries;
|
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 users = await Promise.all(searchEntries.map(async (entry) => {
|
||||||
const rawPassword = entry.userPassword ? entry.userPassword.toString() : '';
|
const rawPassword = entry.userPassword ? entry.userPassword.toString() : '';
|
||||||
const isLegacyMD5 = rawPassword.toUpperCase().startsWith('{MD5}');
|
const isLegacyMD5 = rawPassword.toUpperCase().startsWith('{MD5}');
|
||||||
@@ -251,6 +268,7 @@ User.listDetail = async function(){
|
|||||||
passwordMustChange && 'password',
|
passwordMustChange && 'password',
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
obj.onboardingRequired = obj.onboardingNeeds.length > 0 ? 'yes' : '';
|
obj.onboardingRequired = obj.onboardingNeeds.length > 0 ? 'yes' : '';
|
||||||
|
obj.isServiceAccount = serviceAccountDNs.has(String(obj.dn).toLowerCase()) ? 'yes' : '';
|
||||||
|
|
||||||
return obj;
|
return obj;
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ const permission = require('../utils/permission');
|
|||||||
|
|
||||||
async function resolveRecipients(filter_type, filter_value, active_only) {
|
async function resolveRecipients(filter_type, filter_value, active_only) {
|
||||||
if (filter_type === 'all' || filter_type === 'all_active') {
|
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;
|
const active = filter_type === 'all_active' || active_only;
|
||||||
return active ? users.filter(u => !u.pwdAccountLockedTime) : users;
|
return active ? users.filter(u => !u.pwdAccountLockedTime) : users;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,19 @@ router.post('/', async function(req, res, next){
|
|||||||
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();
|
||||||
await verif.update(updates);
|
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});
|
return res.json({results: user});
|
||||||
}catch(error){
|
}catch(error){
|
||||||
next(error);
|
next(error);
|
||||||
|
|||||||
@@ -552,6 +552,12 @@
|
|||||||
into this UI, no home directory. theta-env's <code>cn=ldapclient</code>
|
into this UI, no home directory. theta-env's <code>cn=ldapclient</code>
|
||||||
bootstrap account (used by theta42/proxy) shows up here too, since it's
|
bootstrap account (used by theta42/proxy) shows up here too, since it's
|
||||||
the same kind of account.
|
the same kind of account.
|
||||||
|
<br>
|
||||||
|
Need an account something actually <i>runs as</i> on a Linux host instead
|
||||||
|
(a media manager, a torrent client, ...) — with a real <code>uidNumber</code>
|
||||||
|
and a group other accounts join for write access? That's a Unix account, not
|
||||||
|
a bind-only one — create it from <a href="/users">Users</a> with
|
||||||
|
<b>This is a service account</b> checked.
|
||||||
</p>
|
</p>
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
|
|||||||
@@ -43,17 +43,63 @@ async function fetchUsernameSuggestions() {
|
|||||||
} catch(e) {}
|
} catch(e) {}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
<% if (locals.adminMode) { %>
|
||||||
|
<script>
|
||||||
|
// A service account (a Unix-style account something runs as -- a media
|
||||||
|
// manager, a torrent client, ...) isn't a person: no birthday, nothing to
|
||||||
|
// agree to, and it gets one name (a username), not a first/last name.
|
||||||
|
// Toggling this swaps the person-shaped fields for a single account-name
|
||||||
|
// field instead of asking for throwaway values.
|
||||||
|
function toggleServiceAccountFields(el){
|
||||||
|
var checked = el.checked;
|
||||||
|
var $form = $(el).closest('form');
|
||||||
|
|
||||||
|
$form.find('[name=dob]').prop('required', !checked).closest('.mb-3').toggle(!checked);
|
||||||
|
$form.find('#tosAgree').prop('required', !checked).closest('.mb-3').toggle(!checked);
|
||||||
|
$form.find('#personNameFields').toggle(!checked);
|
||||||
|
$form.find('#serviceAccountNameField').toggle(checked);
|
||||||
|
|
||||||
|
if(checked){
|
||||||
|
// Filler values so the LDAP schema (inetOrgPerson requires sn) is
|
||||||
|
// satisfied; not shown anywhere, the account name is what matters.
|
||||||
|
$form.find('[name=givenName]').val('Service');
|
||||||
|
$form.find('[name=sn]').val('Account');
|
||||||
|
document.getElementById('selectedUid').value = '';
|
||||||
|
}else{
|
||||||
|
$form.find('[name=givenName]').val('');
|
||||||
|
$form.find('[name=sn]').val('');
|
||||||
|
document.getElementById('usernameSelector').style.display = 'none';
|
||||||
|
document.getElementById('serviceAccountName').value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<% } %>
|
||||||
<form action="user/" method="post" onsubmit="formAJAX(this)">
|
<form action="user/" method="post" onsubmit="formAJAX(this)">
|
||||||
<input type="hidden" class="form-control" name="delete" value="false" />
|
<input type="hidden" class="form-control" name="delete" value="false" />
|
||||||
<div class="mb-3">
|
<% if (locals.adminMode) { %>
|
||||||
<label class="form-label">First name</label>
|
<div class="mb-3 form-check">
|
||||||
<input type="text" class="form-control shadow" name="givenName" placeholder="John" validate=":3" onblur="fetchUsernameSuggestions()" />
|
<input type="checkbox" class="form-check-input" id="isServiceAccount" name="isServiceAccount" onchange="toggleServiceAccountFields(this)">
|
||||||
<div class="invalid-feedback"></div>
|
<label class="form-check-label" for="isServiceAccount">
|
||||||
|
This is a service account <small class="text-muted">(not a person — e.g. a Unix account an app/service runs as)</small>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mb-3" id="serviceAccountNameField" style="display:none">
|
||||||
|
<label class="form-label">Account name</label>
|
||||||
|
<input type="text" class="form-control shadow" id="serviceAccountName" placeholder="stuff_manager"
|
||||||
|
oninput="document.getElementById('selectedUid').value = this.value.trim()" />
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
<div id="personNameFields">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">First name</label>
|
||||||
|
<input type="text" class="form-control shadow" name="givenName" placeholder="John" validate=":3" onblur="fetchUsernameSuggestions()" />
|
||||||
|
<div class="invalid-feedback"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Last name</label>
|
<label class="form-label">Last name</label>
|
||||||
<input type="text" class="form-control shadow" name="sn" placeholder="smith" validate=":3" onblur="fetchUsernameSuggestions()" />
|
<input type="text" class="form-control shadow" name="sn" placeholder="smith" validate=":3" onblur="fetchUsernameSuggestions()" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
|
|||||||
@@ -163,6 +163,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<a href='/users/{{uid}}'>{{givenName}} {{sn}}</a>
|
<a href='/users/{{uid}}'>{{givenName}} {{sn}}</a>
|
||||||
|
{{#isServiceAccount}}<span class="badge bg-secondary" title="Service account — not a person"><i class="fa-solid fa-gears"></i> service</span>{{/isServiceAccount}}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{{mail}}
|
{{mail}}
|
||||||
|
|||||||
+1
-1
@@ -244,7 +244,7 @@ fi
|
|||||||
# ── 8. Required SSO groups ────────────────────────────────────────────────────
|
# ── 8. Required SSO groups ────────────────────────────────────────────────────
|
||||||
info "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}"
|
dn="cn=${group},${GROUP_BASE}"
|
||||||
if dir_search -b "$dn" -s base "(objectClass=*)" dn 2>/dev/null | grep -q "dn:"; then
|
if dir_search -b "$dn" -s base "(objectClass=*)" dn 2>/dev/null | grep -q "dn:"; then
|
||||||
skip "${dn} already exists"
|
skip "${dn} already exists"
|
||||||
|
|||||||
Reference in New Issue
Block a user