Merge pull request #55 from theta42/unix-service-accounts

Add Unix/POSIX service accounts; stop duplicating DEPLOYMENT.md
This commit is contained in:
2026-07-15 20:54:21 -04:00
committed by GitHub
11 changed files with 144 additions and 228 deletions
+5 -2
View File
@@ -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,<base>`.
- **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
against a running slapd (auto-detects the database holding your base DN, and
+4 -2
View File
@@ -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
+9 -205
View File
@@ -7,211 +7,15 @@ title: Deployment
[← Back to Home](index.html)
Two supported methods:
The full deployment guide — Docker (all-in-one image), bare-metal install,
the `app_*` env reference, backups, and the security notes (including why
LDAPS shouldn't be port-forwarded to the internet) — lives in one place to
avoid two copies drifting out of sync:
1. **Docker** — a single all-in-one image bundling the app + OpenLDAP + Redis.
2. **Bare metal**`install.sh` on Debian/Ubuntu (Node.js, OpenLDAP, Redis, systemd unit).
**[DEPLOYMENT.md on GitHub](https://github.com/theta42/sso-manager-node/blob/master/DEPLOYMENT.md)**
## Method 1: Docker (all-in-one)
See also [Configuration](configuration.html) for the config layer merge
order, and [LDAP](ldap.html) for the directory layout and connecting a
3rd-party app.
The image (`Dockerfile.openldap`) bundles OpenLDAP + the app + Redis in one
container. The app connects to the bundled slapd over `localhost:389`
automatically; you only need to set a few secrets.
```bash
# Minimal: an LDAP admin password + a JWT secret, then build + start.
LDAP_ADMIN_PASS='choose-a-strong-password' \
JWT_SECRET="$(openssl rand -hex 32)" \
docker compose up -d --build
```
For a customized deployment, put overrides in a `.env` next to
`docker-compose.yml`:
```env
LDAP_BASE_DN=dc=yourdomain,dc=com
LDAP_DOMAIN=yourdomain.com
LDAP_ADMIN_PASS=your-admin-password
ORG_NAME=Your Org
JWT_SECRET=your-jwt-secret
OAUTH_ISSUER=https://sso.yourdomain.com # browser-facing URL the proxy serves
LDAP_CERT_CN=sso.yourdomain.com # hostname LDAPS clients verify
SMTP_HOST=smtp.yourdomain.com
SMTP_PORT=587
SMTP_USER=noreply@yourdomain.com
SMTP_PASS=your-smtp-password
SMTP_FROM=Your Org <noreply@yourdomain.com>
PORT=3001
LDAPS_PORT=636
```
Then `docker compose up -d --build`.
### Access
- SSO Manager UI: `http://localhost:3001` (HTTP — put a TLS-terminating proxy in front)
- Health: `http://localhost:3001/health``{"status":"ok"}`
- OIDC discovery: `http://localhost:3001/.well-known/openid-configuration`
- LDAPS (legacy apps / direct binds): `ldaps://<host>:636`
### API tokens (personal access tokens)
Any logged-in user can mint a long-lived bearer token to call the management
API from scripts/CI without a browser session. Self-service; authenticates
**as the creator** (carries their LDAP group permissions, re-resolved live).
Create one under **API Tokens** in the UI (shown once), then:
```bash
curl -H "Authorization: Bearer sso_<id>_<secret>" https://sso.example.com/api/user
```
Rotate/revoke from the same page (immediate effect). Optional expiry at
creation. Tokens persist in Redis (AOF) and survive rebuilds.
### Logs
The all-in-one image runs the Node app and slapd (OpenLDAP) in one container,
both writing to the container's stdout/stderr, so `docker compose logs` is the
primary view (slapd runs with `-d 0`, so LDAP output is there too).
```bash
docker compose logs -f sso-manager # app + slapd (stdout/stderr)
docker compose logs --tail=200 --since=10m sso-manager # recent context
docker compose exec sso-manager ldapsearch -x -H ldap://localhost:389 \
-D "cn=admin,$LDAP_BASE_DN" -W -b "$LDAP_BASE_DN" # LDAP health check
```
### Environment variables
| Variable | Default | Description |
|----------|---------|-------------|
| `LDAP_BASE_DN` | `dc=example,dc=com` | slapd suffix + app user/group base |
| `LDAP_DOMAIN` | derived from base DN | DNS domain; default for cert CN + issuer |
| `LDAP_ADMIN_PASS` | `admin` | slapd root password + app bind password |
| `ORG_NAME` | `SSO Manager` | org name in UI/email/group descriptions |
| `JWT_SECRET` | auto-generated | OAuth JWT signing secret (**persist it**) |
| `OAUTH_ISSUER` | `https://sso.<LDAP_DOMAIN>` | OIDC issuer (browser-facing URL) |
| `LDAP_CERT_CN` | `LDAP_DOMAIN` | CN/SAN on the LDAPS cert |
| `LDAP_CERT_DIR` | `/etc/openldap/certs` | look for `ldap.crt`+`ldap.key` here (mount your own) |
| `SMTP_HOST`/`SMTP_PORT`/`SMTP_USER`/`SMTP_PASS`/`SMTP_FROM` | localhost / 587 / empty | outbound email |
| `PORT` | `3001` | host port mapped to the UI |
| `LDAPS_PORT` | `636` | host port mapped to LDAPS |
Any `app_*` var may also be set directly to override any config value — see
[Configuration](configuration.html).
### LDAP TLS (LDAPS / StartTLS)
The bundled slapd generates a **self-signed cert** on first start (CN =
`LDAP_CERT_CN`, valid 10y, SAN = CN + `localhost` + `127.0.0.1`) and listens on
`ldaps:///` (636) + StartTLS on `ldap:///` (389). The cert lives on the
`ldap-certs` volume so it persists across container recreation.
- **Trust it** (clients): copy the cert out and add it to the client's CA store,
or set `TLS_REQCERT never` for quick LAN use:
```bash
docker compose cp sso-manager:/etc/openldap/certs/ldap.crt ./ldap.crt
```
- **Use your own cert**: replace the `ldap-certs` named volume with a bind mount
containing your `ldap.crt` + `ldap.key`. The entrypoint leaves existing certs
untouched.
> Port 389 (plain LDAP) is **not** mapped to the host by default — direct-LDAP
> clients should use LDAPS (636) or StartTLS.
### Backups and restore
LDAP, Redis (OAuth clients + tokens), and the `./config/` secrets are all
persisted and restorable. Redis is now AOF+RDB persisted to the `sso-data`
volume (not in-memory) so OAuth clients survive rebuilds.
See the **Backups and restore** section of `DEPLOYMENT.md` for the full runbook
(what lives where, manual backup, full / Redis-only / LDAP-only restore, and
the AOF-vs-RDB note). Quick LDAP backup:
```bash
docker compose exec sso-manager slapcat -f /etc/openldap/slapd.conf \
-b "dc=yourdomain,dc=com" > ldap-backup-$(date +%F).ldif
```
## Method 2: Bare metal (Debian/Ubuntu)
`install.sh` is an idempotent installer: Node.js 20.x, OpenLDAP (modules +
overlays + custom schema + directory tree + required groups), the app at
`/opt/sso-manager`, and a systemd unit.
```bash
sudo ./install.sh \
-p 'your-ldap-password' \
-b 'dc=yourdomain,dc=com' \
-n 'Your Org' \
-o 3001
```
| Flag | Description |
|------|-------------|
| `-p, --admin-pass` | LDAP admin password (required) |
| `-b, --base-dn` | Base DN (default `dc=example,dc=com`) |
| `-n, --org-name` | Org name (default `SSO Manager`) |
| `-o, --port` | HTTP port (default `3001`) |
| `-j, --jwt-secret` | JWT secret (default auto-generated) |
| `-s, --smtp-config` | SMTP as `host:port:user:pass` |
| `--skip-ldap` | Skip LDAP setup (use existing) |
| `--skip-app` | LDAP setup only |
| `--dry-run` | Show actions without making changes |
Post-install:
```bash
sudo systemctl enable --now sso-manager
curl http://localhost:3001/health # -> {"status":"ok"}
```
For an existing LDAP server, run `sudo ./install.sh --skip-ldap …` and point the
app at it. To (re)configure overlays on an already-installed slapd, prefer
`ops/ldap-setup.sh` (idempotent, auto-detects the user database).
## Fronting with a reverse proxy
The SSO runs HTTP inside the container; terminate TLS at a front proxy. The
[`theta42/proxy`](https://github.com/theta42/proxy) is an OIDC-protected reverse
proxy and a natural fit — it's **both** an OIDC client of this SSO *and* a
direct LDAP client for user lookups.
1. **One Docker network** so the proxy reaches the SSO internally at
`http://sso-manager:3001` (token/userinfo, server-to-server) without exposing
the SSO's HTTP port.
2. **Set the SSO's `OAUTH_ISSUER`** to the *browser-facing* HTTPS URL the proxy
serves the SSO at (e.g. `https://sso.yourdomain.com`).
3. **Register the proxy as an OIDC client** in the SSO UI, with `redirectUri`
matching the proxy's callback (`https://proxy.yourdomain.com/api/auth/oidc/callback`).
4. **LDAP for the proxy**: point `ldap.url` at `ldaps://sso-manager:636` and
create a dedicated service account under `ou=people` (e.g.
`cn=ldapclient,ou=people,…`) — don't reuse the admin DN.
The [`theta42/theta-env`](https://github.com/theta42/theta-env) unified repo
automates all four steps with `./setup.sh` — see
[theta-env docs](https://theta42.github.io/theta-env/).
## Security notes
1. **Never commit `secrets.js`** — it's in `.gitignore`.
2. **Use LDAPS / StartTLS** for any LDAP that crosses the network. Port 389 is
not mapped to the host by default so LAN clients can't bind in cleartext.
3. **Persist `JWT_SECRET`** — an auto-generated one invalidates all tokens on
container recreation.
4. **Don't expose the UI's HTTP port to the internet** — terminate TLS at a
front proxy and keep `3001` on the Docker network / localhost only.
5. **Don't port-forward LDAPS (636) to the internet either.** It's mapped to
the host by default for LAN/VPN clients that bind LDAP directly (other
hosts running `ldap-client`, apps with their own LDAP auth settings) — not
for exposure through your router/firewall. LDAP simple-bind is a
brute-force target and there's no rate limiting in front of it the way
there is for the HTTP login endpoints. If you need a remote host to bind
LDAP, put it behind a VPN (Tailscale, WireGuard, …) instead of forwarding
636 publicly.
6. The all-in-one image runs slapd as the `ldap` user but the app as root
(matches the bare-metal unit). Harden to a non-root user for production.
[← Back to Home](index.html)
[← Back to Home](index.html)
+28 -9
View File
@@ -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,<base>`) 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 \
+19 -1
View File
@@ -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;
}));
+5 -1
View File
@@ -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;
}
+13
View File
@@ -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);
+6
View File
@@ -552,6 +552,12 @@
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
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>
<div class="row g-3">
<div class="col-md-4">
+53 -7
View File
@@ -43,17 +43,63 @@ async function fetchUsernameSuggestions() {
} catch(e) {}
}
</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)">
<input type="hidden" class="form-control" name="delete" value="false" />
<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>
<% if (locals.adminMode) { %>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="isServiceAccount" name="isServiceAccount" onchange="toggleServiceAccountFields(this)">
<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 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">
<label class="form-label">Last name</label>
<input type="text" class="form-control shadow" name="sn" placeholder="smith" validate=":3" onblur="fetchUsernameSuggestions()" />
<div class="mb-3">
<label class="form-label">Last name</label>
<input type="text" class="form-control shadow" name="sn" placeholder="smith" validate=":3" onblur="fetchUsernameSuggestions()" />
</div>
</div>
<div class="mb-3">
+1
View File
@@ -163,6 +163,7 @@
</td>
<td>
<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>
{{mail}}
+1 -1
View File
@@ -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"