4a592f9795
Closes the end-user half of the directory and adds nested LDAP groups.
The directory could describe the lab but could not tell anyone what they had
or how to reach it, and several of the paths meant to do so were silently
returning nothing:
- GET /api/discovery/me resolved groups from req.user.groups, which does not
exist (req.user carries memberOf), so it returned only isPublic resources
for every human caller -- "My Services" was blank for everyone. The same
read made isDirectoryAdmin() false for real admins.
- The portal's "Discover More Services" called the admin-gated endpoint and
swallowed the 403, so it never rendered for non-admins at all.
- Services reported no address, because /me had reimplemented getMyAccess
without its parent-walking resolution.
Adds the catalog at /, self-service access requests, and admin access
visibility (per-resource counts, and the reverse "what can this user reach").
Nested groups come in two halves. groupOfNames.member already accepts a group
DN, so nesting needs no schema -- what it needs is resolution, which no
released OpenLDAP performs. The all-in-one image therefore builds slapd from a
pinned master commit for the nestgroup overlay, and the app computes the
closure itself when pointed at a server without it. Both paths are covered.
member-values is deliberately left out of nestgroup-flags: it expands `member`
when reading a group, which destroys the distinction between "listed here" and
"reachable through a nested group" and is not recoverable afterwards.
Full suite green in both resolution modes: 215 passed, 2 skipped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
227 lines
11 KiB
Docker
227 lines
11 KiB
Docker
# Theta42 SSO Manager with OpenLDAP - All-in-One Dockerfile
|
|
# App + OpenLDAP in a single container, for development/testing or a
|
|
# self-contained single-node deployment. For production, run a dedicated
|
|
# LDAP server and configure the app via app_* env vars / mounted secrets.js.
|
|
|
|
# ── Git commit hash (build-time only) ────────────────────────────────────────
|
|
# The final image intentionally has no git binary and no .git directory (kept
|
|
# lean, per .dockerignore), so `git rev-parse` always fails at runtime and
|
|
# build_info.js silently fell back to "unknown". Resolve it here instead,
|
|
# where .git IS available (build context), and bake just the short hash into
|
|
# a file — this stage itself is discarded, only /commit.txt survives via the
|
|
# COPY --from below. Reuses the main base image (already pulled for the real
|
|
# build below) rather than a separate one, so this adds no extra image pull.
|
|
#
|
|
# GIT_COMMIT lets a caller override the resolved hash instead of computing it
|
|
# from .git in this build context. Needed when this repo is built as a git
|
|
# submodule (e.g. from theta-env): a submodule's .git is a pointer FILE, not
|
|
# a directory — the real object database lives in the superproject's
|
|
# .git/modules/, outside this repo's own directory and therefore outside
|
|
# Docker's build context entirely, so `git rev-parse` can never resolve it
|
|
# from in here no matter what. theta-env's setup.sh passes --build-arg
|
|
# GIT_COMMIT=$(git -C sso-manager-node rev-parse --short HEAD), computed on
|
|
# the host where the submodule resolves correctly.
|
|
ARG GIT_COMMIT=""
|
|
FROM node:20-alpine AS gitinfo
|
|
ARG GIT_COMMIT
|
|
WORKDIR /repo
|
|
COPY .git ./.git
|
|
RUN if [ -n "$GIT_COMMIT" ]; then \
|
|
echo "$GIT_COMMIT" > /commit.txt; \
|
|
else \
|
|
{ apk add --no-cache git \
|
|
&& git rev-parse --short HEAD > /commit.txt; } 2>/dev/null || echo unknown > /commit.txt; \
|
|
fi
|
|
|
|
# ── OpenLDAP from source ─────────────────────────────────────────────────────
|
|
# We build slapd from OpenLDAP master rather than installing Alpine's packages,
|
|
# for exactly one feature: the `nestgroup` overlay (ITS#10161, Howard Chu,
|
|
# 2024-03-21), which evaluates nested groups server-side. Nothing in any 2.6.x
|
|
# release can do this -- verified: 2.6.13 ships 26 overlay modules and
|
|
# nestgroup is not among them -- and the alternative is resolving nesting
|
|
# separately in every consumer (this app, SSSD on each host, jump-host, proxy),
|
|
# where any consumer that forgets silently under-grants access.
|
|
#
|
|
# Consequence to know about: master ships LMDB 1.0.0, whose on-disk format the
|
|
# 0.9.x used by 2.6.x cannot read, and vice versa
|
|
# ("MDB_INVALID: File is not an LMDB file"). Moving an existing directory onto
|
|
# this image is a slapcat/slapadd migration, not a restart. See DEPLOYMENT.md.
|
|
FROM node:20-alpine AS ldapbuild
|
|
|
|
# groff is not optional despite producing nothing we ship: the build descends
|
|
# into doc/man unconditionally and its Makefile calls soelim, which groff
|
|
# provides. Without it the whole `make` fails at the man-page stage
|
|
# ("soelim: not found") long after slapd itself has compiled fine.
|
|
RUN apk add --no-cache \
|
|
build-base autoconf automake libtool \
|
|
openssl-dev cyrus-sasl-dev \
|
|
git make pkgconf util-linux-dev groff
|
|
|
|
# Pinned to an exact commit, not a branch tip. This is the directory server the
|
|
# whole lab authenticates against; an unpinned `master` would mean every image
|
|
# rebuild silently ships whatever landed upstream that morning, and a bad day on
|
|
# master would take out logins with no way to tell what changed.
|
|
#
|
|
# TODO: drop this whole from-source stage once nestgroup ships in a release.
|
|
# It is master-only today (ITS#10161, 2024-03-21); the 2.7 roadmap has slipped
|
|
# from Fall 2024 to Fall 2025 and is still unreleased. When 2.7 lands with
|
|
# nestgroup, revert to `apk add openldap openldap-overlay-nestgroup ...` --
|
|
# the entrypoint already probes for nestgroup.so and needs no change, and the
|
|
# app already keys off app_ldap__nestedGroupsServerSide either way.
|
|
ARG OPENLDAP_COMMIT=350e9eb38b2270c2bad97c61ee02e85fb8f3196d
|
|
|
|
WORKDIR /src
|
|
RUN git init -q . \
|
|
&& git remote add origin https://git.openldap.org/openldap/openldap.git \
|
|
&& git fetch -q --depth 1 origin "${OPENLDAP_COMMIT}" \
|
|
&& git checkout -q FETCH_HEAD \
|
|
&& git rev-parse HEAD > /opt-openldap-commit.txt
|
|
|
|
# Overlays are built as loadable modules (=mod) because docker-entrypoint.sh
|
|
# `moduleload`s them individually; nestgroup joins that set.
|
|
RUN ./configure \
|
|
--prefix=/opt/openldap \
|
|
--enable-slapd \
|
|
--enable-modules \
|
|
--enable-mdb \
|
|
--enable-memberof=mod \
|
|
--enable-refint=mod \
|
|
--enable-ppolicy=mod \
|
|
--enable-dynlist=mod \
|
|
--enable-nestgroup=mod \
|
|
--enable-syncprov=mod \
|
|
--enable-auditlog=mod \
|
|
--with-tls=openssl \
|
|
--with-cyrus-sasl \
|
|
&& make depend \
|
|
&& make -j"$(nproc)" \
|
|
&& make install
|
|
|
|
# pw-sha2 provides {SSHA512}, which every existing user password is stored as.
|
|
# It lives in contrib and is not covered by the configure flags above, so it is
|
|
# built separately against the just-built tree -- omitting it would make every
|
|
# user password unverifiable.
|
|
RUN cd contrib/slapd-modules/passwd/sha2 \
|
|
&& make prefix=/opt/openldap OPENLDAP_SRC=/src \
|
|
&& cp .libs/pw-sha2.so* /opt/openldap/libexec/openldap/
|
|
|
|
FROM node:20-alpine
|
|
|
|
# Runtime libraries the from-source slapd links against, plus the app's own
|
|
# deps. No openldap* packages here: everything LDAP comes from /opt/openldap.
|
|
# libltdl (module loading -- slapd is useless without it, since every overlay
|
|
# is a loadable module) and libuuid are pulled in by the source build but are
|
|
# NOT dependencies of anything else here, so they must be named explicitly;
|
|
# omitting them fails at runtime with "Error relocating ... lt_dlopenext:
|
|
# symbol not found", not at build time.
|
|
RUN apk add --no-cache \
|
|
openssl \
|
|
libsasl \
|
|
libltdl \
|
|
libuuid \
|
|
dumb-init \
|
|
bash \
|
|
redis \
|
|
&& rm -rf /var/cache/apk/*
|
|
|
|
COPY --from=ldapbuild /opt/openldap /opt/openldap
|
|
# Which upstream commit this slapd was built from — so a running container can
|
|
# answer "what am I actually running" without rebuilding.
|
|
COPY --from=ldapbuild /opt-openldap-commit.txt /opt/openldap/COMMIT
|
|
|
|
# The Alpine openldap package used to create these; nothing does now, and
|
|
# docker-entrypoint.sh runs slapd as -u ldap -g ldap.
|
|
RUN addgroup -S ldap 2>/dev/null || true \
|
|
&& adduser -S -D -H -G ldap ldap 2>/dev/null || true
|
|
|
|
# docker-entrypoint.sh invokes slapd/slappasswd/ldapadd/ldapsearch by bare name
|
|
# and probes a list of candidate module directories, so putting the from-source
|
|
# tree first on PATH is all that is needed to redirect it. Schemas are symlinked
|
|
# into the conventional location because the entrypoint's slapd.conf includes
|
|
# /etc/openldap/schema/*.schema, and the app's own schemas (theta42, sudo,
|
|
# openssh-lpk) are copied there too.
|
|
ENV PATH="/opt/openldap/bin:/opt/openldap/sbin:/opt/openldap/libexec:${PATH}"
|
|
RUN mkdir -p /etc/openldap/schema \
|
|
&& for f in /opt/openldap/etc/openldap/schema/*.schema; do \
|
|
ln -sf "$f" "/etc/openldap/schema/$(basename "$f")"; \
|
|
done
|
|
|
|
WORKDIR /app
|
|
|
|
# Create required directories. slapd runs as the ldap user; the app process
|
|
# runs as root in this image (matches the bare-metal systemd unit).
|
|
RUN mkdir -p /var/lib/ldap /etc/ldap/sasl2 && \
|
|
chown -R ldap:ldap /var/lib/ldap /etc/ldap/sasl2
|
|
|
|
# Copy application source and install production dependencies.
|
|
# .dockerignore excludes nodejs/node_modules so npm ci builds a clean tree.
|
|
COPY nodejs/package*.json ./
|
|
RUN npm ci --omit=dev
|
|
|
|
COPY nodejs/app.js ./
|
|
COPY nodejs/bin ./bin
|
|
COPY nodejs/conf ./conf
|
|
COPY nodejs/controller ./controller
|
|
COPY nodejs/middleware ./middleware
|
|
COPY nodejs/models ./models
|
|
COPY nodejs/routes ./routes
|
|
COPY nodejs/services ./services
|
|
COPY nodejs/utils ./utils
|
|
COPY nodejs/views ./views
|
|
COPY nodejs/public ./public
|
|
|
|
# routes/index.js reads path.join(__dirname, '../../tos.md') at boot. With the
|
|
# app flattened into /app, __dirname is /app/routes and ../../ resolves to /,
|
|
# so the file must exist at /tos.md (mirroring the repo where tos.md sits one
|
|
# level above the nodejs/ app dir). Without this the app crashes on startup.
|
|
COPY tos.md /tos.md
|
|
|
|
# Documentation, served in-app at /docs (routes/docs.js) so it's readable
|
|
# without internet access. Same flattened-path convention as tos.md above.
|
|
COPY README.md /README.md
|
|
COPY CHANGELOG.md /CHANGELOG.md
|
|
COPY DEPLOYMENT.md /DEPLOYMENT.md
|
|
COPY API.md /API.md
|
|
COPY directory_spec.md /directory_spec.md
|
|
COPY docs /docs
|
|
|
|
# Baked commit hash from the gitinfo stage (see build_info.js).
|
|
COPY --from=gitinfo /commit.txt ./.build_commit
|
|
|
|
# Copy startup script
|
|
COPY docker-entrypoint.sh /usr/local/bin/
|
|
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
|
|
|
# Custom LDAP schemas the app's user model depends on. addPosixAccount
|
|
# (nodejs/models/user_ldap.js) tags every user entry with objectClasses
|
|
# theta42Person, sudoRole, and ldapPublicKey and writes dateOfBirth /
|
|
# sudoHost,sudoCommand,sudoUser / sshPublicKey — so the directory must know
|
|
# all three or user create/update fails (theta42Person: LDAP 0x15; sudoRole /
|
|
# ldapPublicKey: objectClassViolation 65). .dockerignore excludes ops/ from
|
|
# the build context except ops/schema/*.schema, which is why these COPYs work.
|
|
# theta42.schema : dateOfBirth + theta42Person (mirrors ldap-setup.sh §5)
|
|
# sudo.schema : sudoRole (AUXILIARY — see file header) + sudo* attributes
|
|
# openssh-lpk.schema : sshPublicKey + ldapPublicKey (AUXILIARY)
|
|
COPY ops/schema/theta42.schema /etc/openldap/schema/theta42.schema
|
|
COPY ops/schema/sudo.schema /etc/openldap/schema/sudo.schema
|
|
COPY ops/schema/openssh-lpk.schema /etc/openldap/schema/openssh-lpk.schema
|
|
|
|
# Expose ports
|
|
# 3001: SSO Manager web interface (HTTP — terminate TLS at the front proxy)
|
|
# 389: LDAP (plain + StartTLS) — used internally by the app; map to host only
|
|
# if you want LAN clients to bind without TLS (not recommended).
|
|
# 636: LDAPS — direct LDAP binds over the network (TLS): Linux host auth
|
|
# (PAM/SSSD, sudo, SSH keys) and LDAP-native apps
|
|
EXPOSE 3001 389 636
|
|
|
|
# Health check
|
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
|
CMD wget --no-verbose --tries=1 --spider http://localhost:3001/health || exit 1
|
|
|
|
# dumb-init reaps zombies and forwards signals to the node process the
|
|
# entrypoint execs into. Without it SIGTERM from `docker stop` is ignored
|
|
# and the container hits the 10s kill timeout.
|
|
ENTRYPOINT ["dumb-init", "/usr/local/bin/docker-entrypoint.sh"]
|
|
|
|
CMD ["node", "bin/www"]
|