8a9de94d24
* feat: complete the group model (god_admin, site groups, aggregates), enforce naming, fix docs 500s + status dots (v1.26.0)
- seed god_admin + nest into app_super_admin; auto-provision site groups (S_super_admin, S_hosts_*/S_apps_* aggregates, S_everyone) on site create + self-heal on Directory load
- map service resources to the app kind (site_local_app_<slug>_*); nest per-resource groups into site aggregates (physical inheritance lattice)
- enforce the group naming convention server-side on POST /groups; surface god_admin + site groups on the site resource modal
- fix in-app /docs/<slug> 500s (Dockerfile never copied docs/); serve doc images at /docs/images
- fix Directory status dots (neutral grey when agent endpoint unreachable); align Profile/API cards full-width
- group resolver: keep the site slug verbatim (site_local not re-slugified)
- bump to 1.26.0
* fix: use verbatim resource slugs in group names (matches access-request tests + live convention)
The group naming inserts a kind segment (resourceGroupCns(site, kind, slug, level)),
but the access-request tests + the live directory convention are verbatim
({site}_{slug}_{level} -- the kind is carried in the resource slug, e.g. host_theta-env).
For bare test slugs this produced site_x_host_artest-host_x_access instead of the
expected site_x_artest-host_x_access, so the requester was never removed from the
auto-provisioned access group and every request 409'd. resourceGroupCns is now
(site, slug, level) with the verbatim slug; the kind is used only to pick the
aggregate the group nests into.
496 lines
24 KiB
Bash
Executable File
496 lines
24 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# docker-entrypoint.sh — initialize the bundled OpenLDAP and start SSO Manager.
|
|
# Used by Dockerfile.openldap (the all-in-one image: app + slapd in one container).
|
|
#
|
|
# This is intended for development/testing / single-node deployments. For
|
|
# production, run a dedicated LDAP server and point the app at it via app_*
|
|
# env vars (or a mounted conf/secrets.js) using the app-only image.
|
|
#
|
|
# The app reads its configuration from conf/base.js + a secrets file, deep-merged
|
|
# by @simpleworkjs/conf (requires >= 1.2.0, pinned in nodejs/package-lock.json),
|
|
# with `app_*` environment variables as the highest-precedence override layer.
|
|
# This entrypoint exports those `app_*` vars so the app connects to the bundled
|
|
# slapd without any mounted secrets file. Any `app_*` var already set in the
|
|
# environment wins (the values below are defaults/fallbacks only).
|
|
|
|
set -e
|
|
|
|
# ── LDAP server-side configuration ──────────────────────────────────────────
|
|
LDAP_BASE_DN="${LDAP_BASE_DN:-dc=example,dc=com}"
|
|
LDAP_ADMIN_PASS="${LDAP_ADMIN_PASS:-admin}"
|
|
# Derive the DNS domain from the base DN (dc=foo,dc=bar -> foo.bar) unless given.
|
|
if [[ -z "${LDAP_DOMAIN:-}" ]]; then
|
|
LDAP_DOMAIN=$(echo "$LDAP_BASE_DN" | sed 's/^dc=//; s/,dc=/./g')
|
|
fi
|
|
ORG_NAME="${ORG_NAME:-SSO Manager}"
|
|
LDAP_BIND_DN="${LDAP_BIND_DN:-cn=admin,${LDAP_BASE_DN}}"
|
|
# The app (inside the container) talks to the local slapd over localhost.
|
|
APP_LDAP_URL="${app_ldap__url:-ldap://localhost:389}"
|
|
|
|
info() { echo "[INFO] $*"; }
|
|
error() { echo "[ERROR] $*" >&2; }
|
|
|
|
# ── Optional: load operational config from a mounted secrets.js ──────────────
|
|
# The unified theta-env stack mounts ./config/sso-secrets.js at /config and
|
|
# treats it as the authoritative source for the SSO's config (LDAP base, admin
|
|
# password, org name, JWT secret, ...). When present, point CONF_SECRETS at it
|
|
# so @simpleworkjs/conf reads it directly (no write access to /app/conf
|
|
# needed), and override the env-derived operational vars below with the
|
|
# file's values. When absent (standalone / env-var deployments) the env vars
|
|
# set above stay in effect and the app_* exports further down are emitted as
|
|
# before.
|
|
SECRETS_JS_MODE=0
|
|
if [[ -f /config/sso-secrets.js ]]; then
|
|
export CONF_SECRETS=/config/sso-secrets.js
|
|
SECRETS_JS_MODE=1
|
|
# Pull the entrypoint's operational vars out of secrets.js in one node call.
|
|
# Node emits `KEY<TAB>base64(value)` lines; we decode each with base64 -d and
|
|
# assign via printf -v. base64 carries quotes / special chars safely with no
|
|
# eval and no shell-quoting gymnastics. No app_* env is exported in this mode
|
|
# — @simpleworkjs/conf reads the file directly, and any app_* env would
|
|
# override it (precedence: base.js < <env>.js < secrets.js < app_* env).
|
|
_node_out="$(node -e '
|
|
const c = require("/config/sso-secrets.js");
|
|
const b = s => Buffer.from(String(s == null ? "" : s)).toString("base64");
|
|
const o = {
|
|
LDAP_BASE_DN: (c.stack && c.stack.ldapBaseDn) || "",
|
|
LDAP_ADMIN_PASS: (c.ldap && c.ldap.bindPassword) || "",
|
|
ORG_NAME: c.name || "",
|
|
LDAP_DOMAIN: (c.stack && c.stack.ldapDomain) || "",
|
|
LDAP_CERT_CN: (c.stack && c.stack.ldapCertCn) || "",
|
|
JWT_SECRET: (c.oauth && c.oauth.jwtSecret) || "",
|
|
};
|
|
for (const k in o) console.log(k + "\t" + b(o[k]));
|
|
')" || { error "Failed to parse /config/sso-secrets.js (see stderr above)"; exit 1; }
|
|
[[ -n "$_node_out" ]] || { error "/config/sso-secrets.js produced no config"; exit 1; }
|
|
while IFS=$'\t' read -r _k _v; do
|
|
[[ -n "$_k" ]] || continue
|
|
printf -v "$_k" '%s' "$(printf '%s' "$_v" | base64 -d)"
|
|
done <<< "$_node_out"
|
|
LDAP_BIND_DN="cn=admin,${LDAP_BASE_DN}"
|
|
[[ -n "$LDAP_ADMIN_PASS" ]] || { error "/config/sso-secrets.js: ldap.bindPassword is empty"; exit 1; }
|
|
[[ -n "$JWT_SECRET" ]] || { error "/config/sso-secrets.js: oauth.jwtSecret is empty"; exit 1; }
|
|
info "Loaded config from /config/sso-secrets.js (secrets.js authoritative)"
|
|
fi
|
|
|
|
# ── Locate the OpenLDAP module directory ────────────────────────────────────
|
|
# slapd.conf needs `modulepath` to find pw-sha2/ppolicy/memberof/refint. The
|
|
# path varies by distro; auto-detect rather than hardcode.
|
|
# /opt/openldap/libexec/openldap is first: that is the from-source build (see
|
|
# Dockerfile.openldap), which is the only one carrying the nestgroup overlay.
|
|
MODULE_PATH=""
|
|
for p in /opt/openldap/libexec/openldap /usr/lib/openldap /usr/lib/ldap /usr/local/lib/openldap /opt/local/lib/openldap; do
|
|
if [[ -d "$p" ]]; then MODULE_PATH="$p"; break; fi
|
|
done
|
|
|
|
# Nested-group support is only available when slapd was built with the
|
|
# nestgroup overlay. Detect rather than assume, so this entrypoint still
|
|
# produces a working slapd.conf against a distro OpenLDAP (where the app falls
|
|
# back to resolving nesting itself -- see nodejs/models/group_ldap.js).
|
|
NESTGROUP_AVAILABLE=0
|
|
if [[ -n "$MODULE_PATH" && -f "$MODULE_PATH/nestgroup.so" ]]; then
|
|
NESTGROUP_AVAILABLE=1
|
|
fi
|
|
|
|
# ── TLS certificate for LDAPS / StartTLS ────────────────────────────────────
|
|
# Legacy apps (e.g. the theta42/proxy, Gitea, Emby) bind to LDAP directly over the
|
|
# network. To keep password binds off the wire in cleartext we expose LDAPS
|
|
# (636) and allow StartTLS on 389. By default a self-signed cert is generated on
|
|
# first start; mount your own cert+key at LDAP_CERT_DIR to use a CA-signed cert
|
|
# instead (idempotent: existing certs are never overwritten).
|
|
LDAP_CERT_DIR="${LDAP_CERT_DIR:-/etc/openldap/certs}"
|
|
# CN/SAN hostname clients will verify against. Default to the DNS domain derived
|
|
# from the base DN (dc=foo,dc=bar -> foo.bar); override for a public hostname.
|
|
LDAP_CERT_CN="${LDAP_CERT_CN:-${LDAP_DOMAIN:-localhost}}"
|
|
mkdir -p "$LDAP_CERT_DIR"
|
|
if [[ -f "$LDAP_CERT_DIR/ldap.crt" && -f "$LDAP_CERT_DIR/ldap.key" ]]; then
|
|
info "Using existing LDAP TLS cert at $LDAP_CERT_DIR (mounted or previously generated)"
|
|
else
|
|
info "Generating self-signed LDAP TLS cert (CN=$LDAP_CERT_CN, valid 10y)..."
|
|
openssl req -x509 -newkey rsa:2048 -nodes \
|
|
-keyout "$LDAP_CERT_DIR/ldap.key" -out "$LDAP_CERT_DIR/ldap.crt" \
|
|
-days 3650 -subj "/CN=$LDAP_CERT_CN" \
|
|
-addext "subjectAltName=DNS:$LDAP_CERT_CN,DNS:localhost,IP:127.0.0.1" \
|
|
>/dev/null 2>&1 || {
|
|
error "Failed to generate LDAP TLS cert"
|
|
exit 1
|
|
}
|
|
fi
|
|
# slapd runs as the ldap user and must read both cert and key.
|
|
chown -R ldap:ldap "$LDAP_CERT_DIR" 2>/dev/null || true
|
|
chmod 600 "$LDAP_CERT_DIR/ldap.key" 2>/dev/null || true
|
|
chmod 644 "$LDAP_CERT_DIR/ldap.crt" 2>/dev/null || true
|
|
|
|
# ── Generate slapd.conf ─────────────────────────────────────────────────────
|
|
# We use slapd.conf (static config) rather than cn=config so the whole directory
|
|
# is configured from a single generated file. slapd is started with -f below so
|
|
# it actually reads this file (the original entrypoint omitted -f, so slapd
|
|
# ignored it entirely).
|
|
|
|
cat > /etc/openldap/slapd.conf << SLAPDEOF
|
|
include /etc/openldap/schema/core.schema
|
|
include /etc/openldap/schema/cosine.schema
|
|
include /etc/openldap/schema/inetorgperson.schema
|
|
include /etc/openldap/schema/nis.schema
|
|
include /etc/openldap/schema/theta42.schema
|
|
include /etc/openldap/schema/sudo.schema
|
|
include /etc/openldap/schema/openssh-lpk.schema
|
|
|
|
SERVER_ID_PLACEHOLDER
|
|
|
|
# Module loading (pw-sha2 provides {SSHA512} used by the app for user passwords;
|
|
# ppolicy/memberof/refint are the overlays the app depends on). On OpenLDAP 2.5+
|
|
# the ppolicy schema (pwdPolicy, pwdAccountLockedTime, ...) is built into
|
|
# ppolicy.so and registered when the module loads — there is no separate
|
|
# ppolicy.schema to include, and loading the module before the overlay below
|
|
# is what makes the pwdPolicy objectClass known to slapd.
|
|
SLAPMODULEPATH
|
|
moduleload back_mdb
|
|
moduleload pw-sha2
|
|
moduleload ppolicy
|
|
moduleload memberof
|
|
moduleload refint
|
|
moduleload auditlog
|
|
NESTGROUP_MODULE_PLACEHOLDER
|
|
SYNCPROV_MODULE_PLACEHOLDER
|
|
|
|
# TLS (LDAPS on 636 + StartTLS on 389). Cert/key paths are fixed; the files are
|
|
# generated/mounted above. We accept clients without their own cert (the common
|
|
# case for LDAP bind clients) and treat our own self-signed cert as the CA.
|
|
TLSCertificateFile /etc/openldap/certs/ldap.crt
|
|
TLSCertificateKeyFile /etc/openldap/certs/ldap.key
|
|
TLSCACertificateFile /etc/openldap/certs/ldap.crt
|
|
TLSVerifyClient never
|
|
|
|
# Database configuration
|
|
database mdb
|
|
# Store the mdb files on the persistent volume (docker-compose mounts a named
|
|
# volume at /var/lib/ldap). Without this, mdb defaults to
|
|
# /var/lib/openldap/openldap-data and the directory would be lost on recreation.
|
|
directory /var/lib/ldap
|
|
suffix SUFFIX_PLACEHOLDER
|
|
rootdn BIND_DN_PLACEHOLDER
|
|
# rootpw uses {SSHA} (built into slapd, no module needed) so slappasswd never
|
|
# depends on a loadable module to generate or verify it. User passwords are
|
|
# stored as {SSHA512} by the app itself and verified via the pw-sha2 module.
|
|
rootpw ROOTPW_PLACEHOLDER
|
|
|
|
# Indexes
|
|
index objectClass eq
|
|
index uid eq,sub
|
|
index mail eq,sub
|
|
index cn eq,sub
|
|
index member eq
|
|
index uidNumber eq
|
|
index gidNumber eq
|
|
|
|
# ppolicy overlay (account locking — the app's active/inactive toggle relies on
|
|
# pwdAccountLockedTime being a known attribute).
|
|
overlay ppolicy
|
|
ppolicy_default "cn=ppolicy,ou=policies,SUFFIX_PLACEHOLDER"
|
|
ppolicy_use_lockout true
|
|
|
|
# memberof overlay (reverse group membership)
|
|
overlay memberof
|
|
memberof-group-oc groupOfNames
|
|
memberof-member-ad member
|
|
memberof-memberof-ad memberOf
|
|
|
|
# refint overlay (referential integrity on group membership)
|
|
overlay refint
|
|
refint_attributes memberOf member manager owner
|
|
|
|
NESTGROUP_OVERLAY_PLACEHOLDER
|
|
|
|
# auditlog overlay (LDIF audit trail of all changes)
|
|
overlay auditlog
|
|
auditlog /var/lib/ldap/auditlog.ldif
|
|
|
|
REPLICATION_BLOCK_PLACEHOLDER
|
|
|
|
# Access controls
|
|
access to attrs=userPassword
|
|
by dn="BIND_DN_PLACEHOLDER" write
|
|
by anonymous auth
|
|
by self write
|
|
by * none
|
|
|
|
access to *
|
|
by dn="BIND_DN_PLACEHOLDER" write
|
|
by * read
|
|
SLAPDEOF
|
|
|
|
# Generate the rootpw hash ({SSHA}, built-in — no module dependency).
|
|
HASHED_PASS=$(slappasswd -s "$LDAP_ADMIN_PASS")
|
|
|
|
# Fill placeholders.
|
|
DC_VALUE="${LDAP_BASE_DN%%,*}" # dc=example
|
|
DC_VALUE="${DC_VALUE#dc=}" # example
|
|
sed -i "s|SUFFIX_PLACEHOLDER|${LDAP_BASE_DN}|g" /etc/openldap/slapd.conf
|
|
sed -i "s|BIND_DN_PLACEHOLDER|${LDAP_BIND_DN}|g" /etc/openldap/slapd.conf
|
|
sed -i "s|ROOTPW_PLACEHOLDER|${HASHED_PASS}|g" /etc/openldap/slapd.conf
|
|
if [[ -n "$MODULE_PATH" ]]; then
|
|
sed -i "s|^SLAPMODULEPATH$|modulepath ${MODULE_PATH}|" /etc/openldap/slapd.conf
|
|
else
|
|
sed -i "/^SLAPMODULEPATH$/d" /etc/openldap/slapd.conf
|
|
fi
|
|
|
|
# ── Nested groups (nestgroup overlay) ──
|
|
# Three of the four flags, deliberately:
|
|
#
|
|
# member-filter (member=X) finds parent groups transitively. This is what
|
|
# Group.list(dn) rides on -- the core access question.
|
|
# memberof-filter (memberOf=X) matches members of nested groups. This is
|
|
# what SSSD's ldap_access_filter uses, so SSH/sudo inherit
|
|
# nesting without any client-side walking.
|
|
# memberof-values expands memberOf when reading a user, so anything that
|
|
# reads the attribute rather than searching still sees the
|
|
# full picture.
|
|
#
|
|
# member-values is deliberately NOT enabled. It expands the `member` attribute
|
|
# when reading a *group*, which sounds symmetric but destroys the distinction
|
|
# between "listed on this group" and "reachable through a nested one" -- and
|
|
# that distinction is not recoverable afterwards, because the raw values are
|
|
# simply not returned. The Groups UI needs it to show nested groups as nested
|
|
# rather than as a crowd of phantom users, and un-nesting needs it to know what
|
|
# it is actually removing. Transitive *answers* come from the filter flags and
|
|
# from Group.effectiveMembers(), which computes the closure explicitly.
|
|
if [[ "$NESTGROUP_AVAILABLE" == "1" ]]; then
|
|
info "nestgroup overlay available — nested groups resolved server-side"
|
|
sed -i "s|^NESTGROUP_MODULE_PLACEHOLDER$|moduleload nestgroup|" /etc/openldap/slapd.conf
|
|
NESTGROUP_BLOCK="# nestgroup overlay (server-side nested group evaluation)\noverlay nestgroup\nnestgroup-base ou=groups,${LDAP_BASE_DN}\nnestgroup-flags member-filter memberof-filter memberof-values"
|
|
sed -i "s|^NESTGROUP_OVERLAY_PLACEHOLDER$|${NESTGROUP_BLOCK}|" /etc/openldap/slapd.conf
|
|
else
|
|
info "nestgroup overlay not present in ${MODULE_PATH:-<no module path>} — nested groups will be resolved by the app instead"
|
|
sed -i "/^NESTGROUP_MODULE_PLACEHOLDER$/d" /etc/openldap/slapd.conf
|
|
sed -i "/^NESTGROUP_OVERLAY_PLACEHOLDER$/d" /etc/openldap/slapd.conf
|
|
fi
|
|
|
|
# ── Multi-Master Replication Configuration ──
|
|
if [[ -n "${LDAP_SERVER_ID:-}" && -n "${LDAP_REPLICATION_HOSTS:-}" ]]; then
|
|
info "Configuring Multi-Master replication (Server ID: ${LDAP_SERVER_ID})"
|
|
sed -i "s|^SERVER_ID_PLACEHOLDER|ServerID ${LDAP_SERVER_ID}|" /etc/openldap/slapd.conf
|
|
sed -i "s|^SYNCPROV_MODULE_PLACEHOLDER|moduleload syncprov|" /etc/openldap/slapd.conf
|
|
|
|
# Generate syncrepl blocks
|
|
REPL_BLOCK="overlay syncprov\nsyncprov-checkpoint 100 10\nsyncprov-sessionlog 100\n\n"
|
|
RID=100
|
|
for HOST in ${LDAP_REPLICATION_HOSTS}; do
|
|
RID=$((RID + 1))
|
|
REPL_BLOCK="${REPL_BLOCK}syncrepl rid=${RID}\n provider=${HOST}\n type=refreshAndPersist\n retry=\"60 +\"\n searchbase=\"${LDAP_BASE_DN}\"\n bindmethod=simple\n binddn=\"${LDAP_BIND_DN}\"\n credentials=\"${LDAP_ADMIN_PASS}\"\n\n"
|
|
done
|
|
REPL_BLOCK="${REPL_BLOCK}mirrormode on\n"
|
|
|
|
# Replace placeholder (awk is safer for multiline replacements than sed)
|
|
awk -v repl="$(printf '%b' "$REPL_BLOCK")" '{gsub(/REPLICATION_BLOCK_PLACEHOLDER/, repl)}1' /etc/openldap/slapd.conf > /etc/openldap/slapd.conf.tmp
|
|
mv /etc/openldap/slapd.conf.tmp /etc/openldap/slapd.conf
|
|
else
|
|
sed -i "/^SERVER_ID_PLACEHOLDER/d" /etc/openldap/slapd.conf
|
|
sed -i "/^SYNCPROV_MODULE_PLACEHOLDER/d" /etc/openldap/slapd.conf
|
|
sed -i "/^REPLICATION_BLOCK_PLACEHOLDER/d" /etc/openldap/slapd.conf
|
|
fi
|
|
|
|
chown ldap:ldap /etc/openldap/slapd.conf 2>/dev/null || true
|
|
chown -R ldap:ldap /var/lib/ldap 2>/dev/null || true
|
|
|
|
# ── Start slapd ─────────────────────────────────────────────────────────────
|
|
info "Starting OpenLDAP (base DN: ${LDAP_BASE_DN})..."
|
|
# -f forces slapd to use our generated slapd.conf (not cn=config).
|
|
# -h listens on ldap:/// (389: plain + StartTLS) and ldaps:/// (636: LDAPS).
|
|
# ldapi:/// is intentionally omitted: its default socket dir doesn't exist on
|
|
# Alpine and the container only uses simple bind over ldap://localhost:389.
|
|
slapd -d 256 -u ldap -g ldap -f /etc/openldap/slapd.conf -h "ldap:/// ldaps:///" >> /var/lib/ldap/slapd.log 2>&1 &
|
|
SLAPD_PID=$!
|
|
|
|
# Wait for slapd to answer the root DSE (means it's up, regardless of DB state).
|
|
for i in $(seq 1 30); do
|
|
if ldapsearch -x -H ldap://localhost:389 -b "" -s base "(objectClass=*)" >/dev/null 2>&1; then
|
|
info "OpenLDAP is ready"
|
|
break
|
|
fi
|
|
info "Waiting for OpenLDAP... ($i/30)"
|
|
sleep 1
|
|
done
|
|
|
|
if ! kill -0 "$SLAPD_PID" 2>/dev/null; then
|
|
error "OpenLDAP failed to start"
|
|
exit 1
|
|
fi
|
|
|
|
# ── Initialize the directory tree (idempotent) ──────────────────────────────
|
|
# Only seed if the base DN doesn't exist yet, so restarting the container is safe.
|
|
if ! ldapsearch -x -H ldap://localhost:389 -b "$LDAP_BASE_DN" -s base "(objectClass=*)" >/dev/null 2>&1; then
|
|
info "Initializing LDAP directory..."
|
|
|
|
ldapadd -x -D "$LDAP_BIND_DN" -w "$LDAP_ADMIN_PASS" -H ldap://localhost:389 << EOF
|
|
dn: ${LDAP_BASE_DN}
|
|
objectClass: dcObject
|
|
objectClass: organization
|
|
dc: ${DC_VALUE}
|
|
o: ${ORG_NAME}
|
|
|
|
dn: ou=people,${LDAP_BASE_DN}
|
|
objectClass: organizationalUnit
|
|
ou: people
|
|
|
|
dn: ou=groups,${LDAP_BASE_DN}
|
|
objectClass: organizationalUnit
|
|
ou: groups
|
|
|
|
dn: ou=policies,${LDAP_BASE_DN}
|
|
objectClass: organizationalUnit
|
|
ou: policies
|
|
|
|
dn: cn=ppolicy,ou=policies,${LDAP_BASE_DN}
|
|
objectClass: top
|
|
objectClass: organizationalRole
|
|
objectClass: pwdPolicy
|
|
cn: ppolicy
|
|
pwdAttribute: 2.5.4.35
|
|
pwdLockout: TRUE
|
|
pwdMustChange: FALSE
|
|
pwdAllowUserChange: TRUE
|
|
EOF
|
|
|
|
# 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.
|
|
#
|
|
# god_admin is the global super group (docs/GROUPS.md §2), the top of the
|
|
# group-inheritance lattice. It is seeded here so it exists from first boot;
|
|
# the theta-suite bootstrap puts the first admin person into it.
|
|
for group in god_admin app_super_admin 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
|
|
objectClass: top
|
|
cn: ${group}
|
|
description: ${ORG_NAME} ${group} group
|
|
member: ${LDAP_BIND_DN}
|
|
EOF
|
|
done
|
|
|
|
# Nest app_super_admin into the SSO admin groups, so cross-app super admins
|
|
# hold those rights by membership rather than by a special case in app code.
|
|
# This is what makes the privilege visible to every consumer -- SSSD, sudo,
|
|
# anything binding LDAP directly -- instead of only to callers that happen
|
|
# to route through utils/permission.js.
|
|
#
|
|
# app_sso_service_account is deliberately excluded: it is a marker for
|
|
# non-person accounts, not a permission, and nesting admins into it would
|
|
# misclassify them as service accounts on the Users page.
|
|
if [[ "$NESTGROUP_AVAILABLE" == "1" ]]; then
|
|
for group in app_sso_admin app_sso_invite app_sso_oauth_admin; do
|
|
ldapmodify -x -D "$LDAP_BIND_DN" -w "$LDAP_ADMIN_PASS" -H ldap://localhost:389 >/dev/null 2>&1 << EOF || true
|
|
dn: cn=${group},ou=groups,${LDAP_BASE_DN}
|
|
changetype: modify
|
|
add: member
|
|
member: cn=app_super_admin,ou=groups,${LDAP_BASE_DN}
|
|
EOF
|
|
done
|
|
info "Nested app_super_admin into the SSO admin groups"
|
|
|
|
# god_admin is the top of the lattice; nesting it into app_super_admin
|
|
# (which is itself nested into the app_sso_* groups above) makes it
|
|
# resolve to everything app_super_admin holds at the LDAP level too.
|
|
ldapmodify -x -D "$LDAP_BIND_DN" -w "$LDAP_ADMIN_PASS" -H ldap://localhost:389 >/dev/null 2>&1 << EOF || true
|
|
dn: cn=app_super_admin,ou=groups,${LDAP_BASE_DN}
|
|
changetype: modify
|
|
add: member
|
|
member: cn=god_admin,ou=groups,${LDAP_BASE_DN}
|
|
EOF
|
|
info "Nested god_admin into app_super_admin"
|
|
fi
|
|
|
|
info "LDAP directory initialized"
|
|
else
|
|
info "LDAP directory already initialized — skipping seed"
|
|
fi
|
|
|
|
# ── Start Redis ──────────────────────────────────────────────────────────────
|
|
# The app stores models/sessions in Redis (model-redis), and the SSO's
|
|
# non-bootstrap OAuth clients also live there. The all-in-one image bundles a
|
|
# Redis server for a self-contained single-node deployment. Persist it to /data
|
|
# (AOF + RDB) so OAuth clients, tokens, and other Redis-backed state survive
|
|
# container recreation. Point the app at an external Redis instead by setting
|
|
# app_redis__host before starting (then no bundled Redis runs here). redis runs
|
|
# as root in this image, so a root-owned /data is writable.
|
|
if [[ -z "${app_redis__host:-}" ]]; then
|
|
REDIS_DATA_DIR="${REDIS_DATA_DIR:-/data}"
|
|
mkdir -p "$REDIS_DATA_DIR"
|
|
chmod 700 "$REDIS_DATA_DIR"
|
|
info "Starting Redis (AOF persisted to $REDIS_DATA_DIR)..."
|
|
redis-server --daemonize no --dir "$REDIS_DATA_DIR" --appendonly yes \
|
|
--appendfilename appendonly.aof --save 900 1 --save 300 10 --save 60 10000 \
|
|
--dbfilename dump.rdb &
|
|
REDIS_PID=$!
|
|
for i in $(seq 1 15); do
|
|
if redis-cli ping >/dev/null 2>&1; then
|
|
info "Redis is ready"
|
|
break
|
|
fi
|
|
sleep 0.5
|
|
done
|
|
if ! kill -0 "$REDIS_PID" 2>/dev/null; then
|
|
error "Redis failed to start"
|
|
exit 1
|
|
fi
|
|
# App defaults to 127.0.0.1:6379 (the redis client default), so no override
|
|
# is needed when running the bundled Redis.
|
|
fi
|
|
|
|
# ── Generate a JWT secret if none was provided (env mode only) ──────────────
|
|
# In secrets.js mode the JWT secret comes from the file and was validated above.
|
|
if [[ "${SECRETS_JS_MODE:-0}" != 1 && -z "${JWT_SECRET:-}" && -z "${app_oauth__jwtSecret:-}" ]]; then
|
|
if command -v openssl >/dev/null 2>&1; then
|
|
JWT_SECRET=$(openssl rand -hex 32)
|
|
else
|
|
JWT_SECRET="$LDAP_ADMIN_PASS-jwt-secret-$(head -c 16 /dev/urandom | od -An -tx1 | tr -d ' ')"
|
|
fi
|
|
info "Generated JWT secret (set JWT_SECRET or app_oauth__jwtSecret to persist)"
|
|
fi
|
|
|
|
# ── Export app_* config overrides for the SSO Manager process (env mode) ─────
|
|
# In secrets.js mode the app reads /app/conf/secrets.js directly, so we export
|
|
# NO app_* vars — they would override the file (@simpleworkjs/conf precedence:
|
|
# base.js < <env>.js < secrets.js < app_* env). In env mode these remain the
|
|
# highest-precedence layer, derived from the LDAP_* / ORG_NAME / SMTP_* env.
|
|
if [[ "${SECRETS_JS_MODE:-0}" != 1 ]]; then
|
|
export app_ldap__url="${app_ldap__url:-$APP_LDAP_URL}"
|
|
export app_ldap__bindDN="${app_ldap__bindDN:-$LDAP_BIND_DN}"
|
|
export app_ldap__bindPassword="${app_ldap__bindPassword:-$LDAP_ADMIN_PASS}"
|
|
export app_ldap__userBase="${app_ldap__userBase:-ou=people,${LDAP_BASE_DN}}"
|
|
export app_ldap__groupBase="${app_ldap__groupBase:-ou=groups,${LDAP_BASE_DN}}"
|
|
# Tell the app whether slapd resolves nested groups for it. When true the app
|
|
# trusts a plain (member=) search to be transitive; when false it computes
|
|
# the closure itself. Getting this wrong in the "true" direction silently
|
|
# under-grants, so it is derived from the same nestgroup.so probe that
|
|
# decides whether the overlay is configured at all -- never hardcoded.
|
|
if [[ "$NESTGROUP_AVAILABLE" == "1" ]]; then
|
|
export app_ldap__nestedGroupsServerSide="${app_ldap__nestedGroupsServerSide:-true}"
|
|
fi
|
|
export app_oauth__jwtSecret="${app_oauth__jwtSecret:-$JWT_SECRET}"
|
|
# OIDC issuer advertised in /.well-known/openid-configuration. Default to the
|
|
# public https URL on the SSO subdomain of the LDAP domain; override with
|
|
# OAUTH_ISSUER / app_oauth__issuer. Computed here (not in compose) because
|
|
# compose v1 doesn't interpolate nested ${VAR:-...} defaults.
|
|
export app_oauth__issuer="${app_oauth__issuer:-https://sso.${LDAP_DOMAIN}}"
|
|
export app_name="${app_name:-$ORG_NAME}"
|
|
|
|
# SMTP (optional). If no user/pass, disable auth by clearing the user.
|
|
export app_smtp__host="${app_smtp__host:-${SMTP_HOST:-localhost}}"
|
|
export app_smtp__port="${app_smtp__port:-${SMTP_PORT:-587}}"
|
|
if [[ -n "${SMTP_USER:-}" || -n "${SMTP_PASS:-}" ]]; then
|
|
export app_smtp__user="${app_smtp__user:-$SMTP_USER}"
|
|
export app_smtp__pass="${app_smtp__pass:-$SMTP_PASS}"
|
|
else
|
|
export app_smtp__user="${app_smtp__user:-}"
|
|
fi
|
|
fi
|
|
|
|
# HTTP port for the app (bin/www reads NODE_PORT).
|
|
export NODE_PORT="${NODE_PORT:-${PORT:-3001}}"
|
|
export NODE_ENV="${NODE_ENV:-production}"
|
|
|
|
info "Starting SSO Manager on port ${NODE_PORT}..."
|
|
exec "$@" |