From 539819c0aa1ecdcf6e49cbe99108aa3bf2f883ec Mon Sep 17 00:00:00 2001 From: William Mantly Date: Wed, 15 Jul 2026 20:52:32 -0400 Subject: [PATCH 1/2] Stop duplicating DEPLOYMENT.md as docs/deployment.md They'd already drifted apart in wording after a recent edit only landed in one of them, and docs/deployment.md wasn't linked from anywhere (index.md's marketing rewrite dropped the old doc-links list) -- orphaned and duplicate. Replace it with a short pointer to DEPLOYMENT.md on GitHub as the single source of truth, matching the index.md "full docs live in the repo" pattern. Co-Authored-By: Claude Sonnet 5 --- docs/deployment.md | 214 ++------------------------------------------- 1 file changed, 9 insertions(+), 205 deletions(-) diff --git a/docs/deployment.md b/docs/deployment.md index c6fff19..bbfe469 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -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 -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://: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__" 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.` | 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) \ No newline at end of file +[← Back to Home](index.html) From 3790e8001a4874f30aad632d89e8be65ae8f7c43 Mon Sep 17 00:00:00 2001 From: William Mantly Date: Wed, 15 Jul 2026 20:52:50 -0400 Subject: [PATCH 2/2] 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 --- DEPLOYMENT.md | 7 ++-- docker-entrypoint.sh | 6 ++-- docs/ldap.md | 37 +++++++++++++++------ nodejs/models/user_ldap.js | 20 +++++++++++- nodejs/routes/notification.js | 6 +++- nodejs/routes/user.js | 13 ++++++++ nodejs/views/integrations.ejs | 6 ++++ nodejs/views/user_form.ejs | 60 +++++++++++++++++++++++++++++++---- nodejs/views/users.ejs | 1 + ops/ldap-setup.sh | 2 +- 10 files changed, 135 insertions(+), 23 deletions(-) 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"