✨ Adds MariaDB image with direct LDAP PAM authentication
- Introduced a new Containerfile to build a MariaDB image with direct LDAP PAM support. - Implemented a custom PAM module (`pam_ldap_direct.so`) built from the PADL source to avoid dependencies on `libpam-ldap` and `nslcd`. - Updated the entrypoint script to dynamically generate the PAM configuration file (`/etc/pam_ldap.conf`) based on environment variables. - Added a README.md to document the image usage, configuration, and build process. - Created a test suite to validate the image build and entrypoint behavior, ensuring proper handling of environment variables and configuration generation. - Configured health checks and ensured the stock MariaDB entrypoint and lifecycle are preserved.
This commit is contained in:
11 files changed
+519
-282
No files matched your search
+1
-1
@@ -7,4 +7,4 @@ VCHORD_VERSION=1.1.1
|
||||
PGPOOL_VERSION=4.7.2
|
||||
LDAP2PG_VERSION=6.6.0
|
||||
|
||||
MARIADB_VERSION=12
|
||||
MARIADB_VERSION=13.0.2
|
||||
@@ -33,12 +33,12 @@ services:
|
||||
- PGPOOL_VERSION=${PGPOOL_VERSION:-4.7.2}
|
||||
|
||||
mariadb:
|
||||
image: containers.kritikos.io/system/mariadb-extended:${MARIADB_VERSION:-12}
|
||||
image: containers.kritikos.io/system/mariadb-extended:${MARIADB_VERSION:-13.0.2}
|
||||
build:
|
||||
context: mariadb
|
||||
dockerfile: Containerfile
|
||||
args:
|
||||
- MARIADB_VERSION=${MARIADB_VERSION:-12}
|
||||
- MARIADB_VERSION=${MARIADB_VERSION:-13.0.2}
|
||||
|
||||
ldap2mariadb:
|
||||
container_name: ldap2mariadb
|
||||
|
||||
@@ -54,8 +54,6 @@ COPY --from=builder /out/ldap2pg /usr/bin/ldap2pg
|
||||
# Fetch the upstream docker entrypoint pinned to the same release tag.
|
||||
ADD --chmod=0755 https://raw.githubusercontent.com/dalibo/ldap2pg/v${LDAP2PG_VERSION}/docker/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
COPY --chmod=0755 provision-user-db.sh /workspace/provision-user-db.sh
|
||||
|
||||
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||
|
||||
RUN /usr/local/bin/docker-entrypoint.sh --version
|
||||
@@ -1,203 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
: "${BOOTSTRAP_DB:?}"
|
||||
: "${ADMIN_DB:?}"
|
||||
: "${USERS_ROLE:?}" # group whose members each get a personal DB
|
||||
: "${READERS_ROLE:?}"
|
||||
: "${WRITERS_ROLE:?}"
|
||||
: "${DDL_ROLE:?}"
|
||||
: "${HARDEN_DB_PUBLIC:?}" # 1=yes: revoke PUBLIC connect on each user DB
|
||||
|
||||
psql_bootstrap() { psql -v ON_ERROR_STOP=1 -d "$BOOTSTRAP_DB" "$@"; }
|
||||
psql_userdb() { psql -v ON_ERROR_STOP=1 -d "$1" "${@:2}"; }
|
||||
|
||||
qi() {
|
||||
local s="$1"
|
||||
s="${s//\"/\"\"}"
|
||||
printf '"%s"' "$s"
|
||||
}
|
||||
|
||||
# ── 1. Ensure the admin (ldap2pg) database exists ──────────────────────────
|
||||
echo "[*] Ensuring admin database exists: ${ADMIN_DB}"
|
||||
psql_bootstrap -Atc "SELECT 1 FROM pg_database WHERE datname='${ADMIN_DB}';" | grep -qx 1 || \
|
||||
psql_bootstrap -c "CREATE DATABASE $(qi "$ADMIN_DB") OWNER postgres;"
|
||||
|
||||
# ── 2. Discover LDAP-synced login roles ────────────────────────────────────
|
||||
echo "[*] Discovering user roles from PG group: ${USERS_ROLE}"
|
||||
users="$(psql_bootstrap -Atc "
|
||||
SELECT r.rolname
|
||||
FROM pg_roles r
|
||||
JOIN pg_auth_members m ON m.member = r.oid
|
||||
JOIN pg_roles p ON p.oid = m.roleid
|
||||
WHERE p.rolname = '${USERS_ROLE}'
|
||||
AND r.rolcanlogin
|
||||
AND r.rolname <> 'postgres'
|
||||
AND r.rolname !~ '^pg_'
|
||||
ORDER BY 1;
|
||||
")"
|
||||
|
||||
if [[ -z "${users}" ]]; then
|
||||
echo "[*] No members in '${USERS_ROLE}'. Nothing to provision."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── 3. Per-user database provisioning ──────────────────────────────────────
|
||||
while IFS= read -r u; do
|
||||
[[ -z "$u" ]] && continue
|
||||
echo
|
||||
echo "[*] Provisioning DB for user: ${u}"
|
||||
|
||||
# ── 3a. Create the database if missing ───────────────────────────────────
|
||||
if ! psql_bootstrap -Atc "SELECT 1 FROM pg_database WHERE datname='${u}';" | grep -qx 1; then
|
||||
echo " - creating database $(qi "$u") owned by $(qi "$u")"
|
||||
psql_bootstrap -c "CREATE DATABASE $(qi "$u") OWNER $(qi "$u");"
|
||||
else
|
||||
echo " - database exists"
|
||||
fi
|
||||
|
||||
# ── 3b. Fix ownership if needed ──────────────────────────────────────────
|
||||
current_owner="$(psql_bootstrap -Atc "
|
||||
SELECT pg_get_userbyid(datdba)
|
||||
FROM pg_database
|
||||
WHERE datname='${u}';
|
||||
")"
|
||||
if [[ "${current_owner}" != "${u}" ]]; then
|
||||
echo " - fixing db owner: ${current_owner} -> ${u}"
|
||||
psql_bootstrap -c "ALTER DATABASE $(qi "$u") OWNER TO $(qi "$u");"
|
||||
else
|
||||
echo " - db owner OK"
|
||||
fi
|
||||
|
||||
# ── 3c. Harden: revoke PUBLIC connect ────────────────────────────────────
|
||||
if [[ "${HARDEN_DB_PUBLIC}" == "1" ]]; then
|
||||
psql_bootstrap -c "REVOKE ALL ON DATABASE $(qi "$u") FROM PUBLIC;"
|
||||
fi
|
||||
|
||||
# ── 3d. Owner DB-level grants ────────────────────────────────────────────
|
||||
psql_bootstrap -c "GRANT CONNECT, TEMPORARY, CREATE ON DATABASE $(qi "$u") TO $(qi "$u");"
|
||||
|
||||
# ── 3e. Per-owner schema + public schema grants ──────────────────────────
|
||||
psql_userdb "$u" -c "GRANT USAGE, CREATE ON SCHEMA public TO $(qi "$u");" >/dev/null
|
||||
|
||||
# ── 3f. Fine-grained role grants (readers / writers / ddl) ───────────────
|
||||
#
|
||||
# DB-level CONNECT so the marker roles can reach the database at all.
|
||||
for role in "$READERS_ROLE" "$WRITERS_ROLE" "$DDL_ROLE"; do
|
||||
psql_bootstrap -c "GRANT CONNECT ON DATABASE $(qi "$u") TO $(qi "$role");" 2>/dev/null || true
|
||||
done
|
||||
|
||||
# DB-level CREATE for the DDL role: required to install trusted extensions
|
||||
# (e.g. CREATE EXTENSION vector) and to create schemas.
|
||||
psql_bootstrap -c "GRANT CREATE ON DATABASE $(qi "$u") TO $(qi "$DDL_ROLE");" 2>/dev/null || true
|
||||
|
||||
# Schema-level grants inside the user's database
|
||||
for schema in public; do
|
||||
# readers → read-only
|
||||
psql_userdb "$u" -c "GRANT USAGE ON SCHEMA $(qi "$schema") TO $(qi "$READERS_ROLE");" >/dev/null
|
||||
psql_userdb "$u" -c "GRANT SELECT ON ALL TABLES IN SCHEMA $(qi "$schema") TO $(qi "$READERS_ROLE");" >/dev/null
|
||||
psql_userdb "$u" -c "GRANT SELECT ON ALL SEQUENCES IN SCHEMA $(qi "$schema") TO $(qi "$READERS_ROLE");" >/dev/null
|
||||
|
||||
# writers → read + DML
|
||||
psql_userdb "$u" -c "GRANT USAGE ON SCHEMA $(qi "$schema") TO $(qi "$WRITERS_ROLE");" >/dev/null
|
||||
psql_userdb "$u" -c "GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA $(qi "$schema") TO $(qi "$WRITERS_ROLE");" >/dev/null
|
||||
psql_userdb "$u" -c "GRANT USAGE, SELECT, UPDATE ON ALL SEQUENCES IN SCHEMA $(qi "$schema") TO $(qi "$WRITERS_ROLE");" >/dev/null
|
||||
|
||||
# ddl → full DDL (create/alter/drop via ownership proxy)
|
||||
psql_userdb "$u" -c "GRANT USAGE, CREATE ON SCHEMA $(qi "$schema") TO $(qi "$DDL_ROLE");" >/dev/null
|
||||
psql_userdb "$u" -c "GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA $(qi "$schema") TO $(qi "$DDL_ROLE");" >/dev/null
|
||||
psql_userdb "$u" -c "GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA $(qi "$schema") TO $(qi "$DDL_ROLE");" >/dev/null
|
||||
psql_userdb "$u" -c "GRANT ALL PRIVILEGES ON ALL FUNCTIONS IN SCHEMA $(qi "$schema") TO $(qi "$DDL_ROLE");" >/dev/null
|
||||
|
||||
# ── Default privileges: apply to objects created in the future ────────
|
||||
psql_userdb "$u" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA $(qi "$schema") GRANT SELECT ON TABLES TO $(qi "$READERS_ROLE");" >/dev/null
|
||||
psql_userdb "$u" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA $(qi "$schema") GRANT SELECT ON SEQUENCES TO $(qi "$READERS_ROLE");" >/dev/null
|
||||
|
||||
psql_userdb "$u" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA $(qi "$schema") GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO $(qi "$WRITERS_ROLE");" >/dev/null
|
||||
psql_userdb "$u" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA $(qi "$schema") GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO $(qi "$WRITERS_ROLE");" >/dev/null
|
||||
|
||||
psql_userdb "$u" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA $(qi "$schema") GRANT ALL PRIVILEGES ON TABLES TO $(qi "$DDL_ROLE");" >/dev/null
|
||||
psql_userdb "$u" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA $(qi "$schema") GRANT ALL PRIVILEGES ON SEQUENCES TO $(qi "$DDL_ROLE");" >/dev/null
|
||||
psql_userdb "$u" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA $(qi "$schema") GRANT ALL PRIVILEGES ON FUNCTIONS TO $(qi "$DDL_ROLE");" >/dev/null
|
||||
done
|
||||
|
||||
echo " - DB + schema grants OK (owner, readers, writers, ddl)"
|
||||
done <<< "${users}"
|
||||
|
||||
# ── 4. Per-database scoped role grants ─────────────────────────────────────
|
||||
# Discover PG roles matching <dbname>_readers / <dbname>_writers / <dbname>_ddl
|
||||
# and grant them ONLY on the corresponding database.
|
||||
echo
|
||||
echo "[*] Applying per-database scoped grants …"
|
||||
|
||||
apply_perdb_grants() {
|
||||
local db="$1" role="$2" kind="$3"
|
||||
|
||||
# DB-level CONNECT
|
||||
psql_bootstrap -c "GRANT CONNECT ON DATABASE $(qi "$db") TO $(qi "$role");" 2>/dev/null || true
|
||||
|
||||
# DDL roles also need DB-level CREATE to install trusted extensions
|
||||
# (e.g. CREATE EXTENSION vector) and to create schemas.
|
||||
if [[ "$kind" == "ddl" ]]; then
|
||||
psql_bootstrap -c "GRANT CREATE ON DATABASE $(qi "$db") TO $(qi "$role");" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
for schema in public "$db"; do
|
||||
case "$kind" in
|
||||
readers)
|
||||
psql_userdb "$db" -c "GRANT USAGE ON SCHEMA $(qi "$schema") TO $(qi "$role");" >/dev/null
|
||||
psql_userdb "$db" -c "GRANT SELECT ON ALL TABLES IN SCHEMA $(qi "$schema") TO $(qi "$role");" >/dev/null
|
||||
psql_userdb "$db" -c "GRANT SELECT ON ALL SEQUENCES IN SCHEMA $(qi "$schema") TO $(qi "$role");" >/dev/null
|
||||
psql_userdb "$db" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA $(qi "$schema") GRANT SELECT ON TABLES TO $(qi "$role");" >/dev/null
|
||||
psql_userdb "$db" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA $(qi "$schema") GRANT SELECT ON SEQUENCES TO $(qi "$role");" >/dev/null
|
||||
;;
|
||||
writers)
|
||||
psql_userdb "$db" -c "GRANT USAGE ON SCHEMA $(qi "$schema") TO $(qi "$role");" >/dev/null
|
||||
psql_userdb "$db" -c "GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA $(qi "$schema") TO $(qi "$role");" >/dev/null
|
||||
psql_userdb "$db" -c "GRANT USAGE, SELECT, UPDATE ON ALL SEQUENCES IN SCHEMA $(qi "$schema") TO $(qi "$role");" >/dev/null
|
||||
psql_userdb "$db" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA $(qi "$schema") GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO $(qi "$role");" >/dev/null
|
||||
psql_userdb "$db" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA $(qi "$schema") GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO $(qi "$role");" >/dev/null
|
||||
;;
|
||||
ddl)
|
||||
psql_userdb "$db" -c "GRANT USAGE, CREATE ON SCHEMA $(qi "$schema") TO $(qi "$role");" >/dev/null
|
||||
psql_userdb "$db" -c "GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA $(qi "$schema") TO $(qi "$role");" >/dev/null
|
||||
psql_userdb "$db" -c "GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA $(qi "$schema") TO $(qi "$role");" >/dev/null
|
||||
psql_userdb "$db" -c "GRANT ALL PRIVILEGES ON ALL FUNCTIONS IN SCHEMA $(qi "$schema") TO $(qi "$role");" >/dev/null
|
||||
psql_userdb "$db" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA $(qi "$schema") GRANT ALL PRIVILEGES ON TABLES TO $(qi "$role");" >/dev/null
|
||||
psql_userdb "$db" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA $(qi "$schema") GRANT ALL PRIVILEGES ON SEQUENCES TO $(qi "$role");" >/dev/null
|
||||
psql_userdb "$db" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA $(qi "$schema") GRANT ALL PRIVILEGES ON FUNCTIONS TO $(qi "$role");" >/dev/null
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# Query PG for roles whose name matches <something>_readers|_writers|_ddl
|
||||
# and where the <something> part matches an existing database.
|
||||
perdb_roles="$(psql_bootstrap -Atc "
|
||||
SELECT r.rolname, d.datname,
|
||||
CASE
|
||||
WHEN r.rolname LIKE '%\_readers' THEN 'readers'
|
||||
WHEN r.rolname LIKE '%\_writers' THEN 'writers'
|
||||
WHEN r.rolname LIKE '%\_ddl' THEN 'ddl'
|
||||
END AS kind
|
||||
FROM pg_roles r
|
||||
JOIN pg_auth_members m ON m.member = r.oid
|
||||
JOIN pg_roles p ON p.oid = m.roleid AND p.rolname = 'ldap_roles'
|
||||
JOIN pg_database d ON d.datname = regexp_replace(r.rolname, '_(readers|writers|ddl)$', '')
|
||||
WHERE r.rolname ~ '_(readers|writers|ddl)$'
|
||||
AND NOT r.rolcanlogin
|
||||
ORDER BY 1;
|
||||
")" || true
|
||||
|
||||
if [[ -n "${perdb_roles}" ]]; then
|
||||
while IFS='|' read -r rolname dbname kind; do
|
||||
[[ -z "$rolname" ]] && continue
|
||||
echo " - granting ${kind} on DB '${dbname}' to role '${rolname}'"
|
||||
apply_perdb_grants "$dbname" "$rolname" "$kind"
|
||||
done <<< "${perdb_roles}"
|
||||
else
|
||||
echo " - no per-database scoped roles found"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "[*] Done."
|
||||
@@ -0,0 +1,7 @@
|
||||
Containerfile text eol=lf
|
||||
docker-entrypoint.sh text eol=lf
|
||||
pam.d/mariadb text eol=lf
|
||||
conf.d/*.cnf text eol=lf
|
||||
tests/*.py text eol=lf
|
||||
README.md text eol=lf
|
||||
.gitattributes text eol=lf
|
||||
@@ -1,54 +1,72 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
ARG MARIADB_VERSION=latest
|
||||
ARG MARIADB_VERSION=13.0.2
|
||||
FROM mariadb:${MARIADB_VERSION} AS base
|
||||
|
||||
FROM mariadb:${MARIADB_VERSION}
|
||||
FROM base AS pam-build
|
||||
|
||||
RUN apt-get update \
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
build-essential ca-certificates curl libcrypt-dev libldap-dev libpam0g-dev libsasl2-dev perl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /build/pam_ldap
|
||||
RUN curl --fail --show-error --silent --location --proto '=https' --tlsv1.2 \
|
||||
https://codeload.github.com/PADL/pam_ldap/tar.gz/656448f091cbeb9efb3ece08e6868e40b8e7b6f8 \
|
||||
--output /tmp/pam_ldap.tar.gz \
|
||||
&& printf '%s %s\n' \
|
||||
1aa622e85621b9018025e3483956d854566a5bc0636a4e8d9d005093f02dd16b \
|
||||
/tmp/pam_ldap.tar.gz | sha256sum -c - \
|
||||
&& tar -xzf /tmp/pam_ldap.tar.gz --strip-components=1 \
|
||||
&& CFLAGS='-O2 -fPIC -std=gnu89' ./configure \
|
||||
--prefix=/usr/local \
|
||||
--with-ldap-lib=openldap \
|
||||
--with-ldap-conf-file=/etc/pam_ldap.conf \
|
||||
--enable-ssl \
|
||||
&& PERL5LIB=/build/pam_ldap make -j2 \
|
||||
&& nm -D pam_ldap.so | grep -E ' T pam_sm_authenticate(@|$)' \
|
||||
&& nm -D pam_ldap.so | grep -E ' T pam_sm_acct_mgmt(@|$)' \
|
||||
&& grep -F '#define HAVE_LDAP_INITIALIZE 1' config.h \
|
||||
&& grep -F '#define HAVE_LDAP_START_TLS_S 1' config.h
|
||||
|
||||
FROM base
|
||||
|
||||
RUN apt-get update \
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
ca-certificates libcrypt1 libldap2 libpam0g util-linux \
|
||||
&& command -v setpriv \
|
||||
&& update-ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=pam-build --chmod=0755 /build/pam_ldap/pam_ldap.so /usr/local/lib/security/pam_ldap_direct.so
|
||||
COPY --from=pam-build /build/pam_ldap/COPYING /build/pam_ldap/COPYING.LIB /usr/share/doc/pam-ldap-direct/
|
||||
|
||||
# ── AD/LDAP authentication via PAM ──────────────────────────────────────────
|
||||
# MariaDB Community has no native LDAP auth plugin, so AD logins go through the
|
||||
# auth_pam plugin -> a PAM "mariadb" service -> daemonless pam_ldap doing an
|
||||
# LDAP search+bind against Active Directory. This is the analogue of the pgpool
|
||||
# `ldap` method in pool_hba.conf; deliberately NO SSSD/nslcd daemon is added.
|
||||
#
|
||||
# libpam-ldap is the daemonless PAM/LDAP module. The auth_pam plugin ships with
|
||||
# recent MariaDB; the separate plugin package (older layouts) is installed
|
||||
# best-effort and also fixes the setuid auth_pam_tool helper permissions.
|
||||
# Either source must leave auth_pam.so in place: 10-plugins.cnf loads it
|
||||
# unconditionally, so a missing .so would only surface as a server that refuses
|
||||
# to start.
|
||||
RUN set -eu; \
|
||||
apt-get update; \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
libpam-ldap \
|
||||
ca-certificates \
|
||||
gettext-base; \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
mariadb-plugin-auth-pam || true; \
|
||||
rm -rf /var/lib/apt/lists/*; \
|
||||
module=/usr/local/lib/security/pam_ldap_direct.so; \
|
||||
ldd "$module" > /tmp/pam-ldap-dependencies; \
|
||||
if grep -q 'not found' /tmp/pam-ldap-dependencies; then \
|
||||
cat /tmp/pam-ldap-dependencies >&2; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
grep -F 'libldap' /tmp/pam-ldap-dependencies; \
|
||||
rm /tmp/pam-ldap-dependencies; \
|
||||
plugin=/usr/lib/mysql/plugin/auth_pam.so; \
|
||||
[ -e "$plugin" ] || { echo "auth_pam: missing $plugin" >&2; exit 1; }
|
||||
helper_dir=/usr/lib/mysql/plugin/auth_pam_tool_dir; \
|
||||
helper="$helper_dir/auth_pam_tool"; \
|
||||
test -f "$plugin" || { echo "Missing MariaDB auth_pam plugin: $plugin" >&2; exit 1; }; \
|
||||
test -f "$helper" || { echo "Missing MariaDB PAM helper: $helper" >&2; exit 1; }; \
|
||||
chown root:mysql "$helper_dir" "$helper"; \
|
||||
chmod 0750 "$helper_dir"; \
|
||||
chmod 4750 "$helper"; \
|
||||
gosu mysql test -x "$helper"
|
||||
|
||||
RUN mkdir -p /etc/mysql/image.conf.d /etc/mysql/site.conf.d \
|
||||
&& printf '%s\n' \
|
||||
'!includedir /etc/mysql/image.conf.d/' \
|
||||
'!includedir /etc/mysql/site.conf.d/' >> /etc/mysql/mariadb.cnf
|
||||
|
||||
# ── Layered configuration ───────────────────────────────────────────────────
|
||||
# Mirror the postgres image: image-owned defaults load first, user overrides
|
||||
# last (last-wins). The stock image already reads /etc/mysql/conf.d and
|
||||
# /etc/mysql/mariadb.conf.d; we append two include dirs AFTER those so:
|
||||
# image.conf.d -> our defaults (override the base image's 50-server.cnf)
|
||||
# site.conf.d -> a clean "mount your *.cnf here" area (wins over everything)
|
||||
RUN set -eu; \
|
||||
mkdir -p /etc/mysql/image.conf.d /etc/mysql/site.conf.d; \
|
||||
{ \
|
||||
echo '!includedir /etc/mysql/image.conf.d/'; \
|
||||
echo '!includedir /etc/mysql/site.conf.d/'; \
|
||||
} >> /etc/mysql/mariadb.cnf
|
||||
COPY conf.d/ /etc/mysql/image.conf.d/
|
||||
|
||||
# PAM service definition for MariaDB logins (auth_pam calls service "mariadb").
|
||||
COPY pam.d/mariadb /etc/pam.d/mariadb
|
||||
|
||||
# Entrypoint wrapper: renders /etc/pam_ldap.conf from env (keeps the AD bind
|
||||
# secret out of the image), then hands off to the stock mariadb entrypoint.
|
||||
COPY --chmod=0644 pam.d/mariadb /etc/pam.d/mariadb
|
||||
COPY --chmod=0755 docker-entrypoint.sh /usr/local/bin/ldap-entrypoint.sh
|
||||
|
||||
# Report health via the image's bundled healthcheck helper.
|
||||
HEALTHCHECK --interval=10s --timeout=5s --retries=5 --start-period=15s \
|
||||
CMD healthcheck.sh --connect --innodb_initialized || exit 1
|
||||
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
# MariaDB with direct LDAP PAM authentication
|
||||
|
||||
This image keeps MariaDB's `auth_pam` plugin and a direct LDAP PAM module. No nslcd or SSSD daemon
|
||||
is configured or started. The existing SQL accounts `IDENTIFIED VIA pam USING 'mariadb'` and the
|
||||
MaxScale clear-password pass-through configuration remain unchanged.
|
||||
|
||||
## Why the package name was insufficient
|
||||
|
||||
In Ubuntu 26.04, [libpam-ldap](https://packages.ubuntu.com/resolute/libpam-ldap) is a virtual package
|
||||
provided by [libpam-ldapd](https://packages.ubuntu.com/resolute/libpam-ldapd). That implementation's
|
||||
`pam_ldap.so` requires nslcd. An `apt-get install libpam-ldap` request therefore did not install
|
||||
the direct module described by the old image comments.
|
||||
|
||||
The [Containerfile](Containerfile) now builds the direct PADL implementation from
|
||||
[commit 656448f091cbeb9efb3ece08e6868e40b8e7b6f8](https://github.com/PADL/pam_ldap/tree/656448f091cbeb9efb3ece08e6868e40b8e7b6f8).
|
||||
The downloaded archive must match SHA256
|
||||
`1aa622e85621b9018025e3483956d854566a5bc0636a4e8d9d005093f02dd16b`.
|
||||
The build and runtime stages share the same MariaDB base, avoiding cross-distribution library copies.
|
||||
Only the built module and its license files are copied into the runtime stage.
|
||||
|
||||
The module is installed at `/usr/local/lib/security/pam_ldap_direct.so` and selected by that
|
||||
absolute path in [pam.d/mariadb](pam.d/mariadb). A distro module with the same generic
|
||||
`pam_ldap.so` name cannot silently replace it in this PAM stack.
|
||||
|
||||
This is legacy upstream code, not a claim that the distribution now supports it. Its revision,
|
||||
dependencies and authentication behavior need review when updating the image. The build selects
|
||||
the source's GNU C89 dialect and supplies its source directory in `PERL5LIB` for the upstream
|
||||
version generator; neither change suppresses an authentication or TLS failure.
|
||||
|
||||
## Runtime configuration
|
||||
|
||||
The [entrypoint](docker-entrypoint.sh) continues to accept the existing variables:
|
||||
|
||||
| Variable | Meaning |
|
||||
| --- | --- |
|
||||
| `LDAPURI` | One or more whitespace-separated LDAP endpoints, all using the same transport |
|
||||
| `LDAP_BASE` | User-search base DN |
|
||||
| `LDAPBINDDN` | Existing directory search account's bind DN |
|
||||
| `LDAPPASSWORD` | Its password, supplied at runtime, never in the image or repository |
|
||||
| `LDAP_LOGIN_ATTR` | Login-name attribute, such as `sAMAccountName` |
|
||||
| `LDAP_TLS_REQCERT` | `demand` or `hard`; weaker verification is rejected |
|
||||
| `CERTS_GID` | The actual host TLS-key group ID, retained in mysql's groups after the stock privilege drop |
|
||||
|
||||
All values must be single-line, and required credentials have no defaults. The renderer writes
|
||||
one root-only `/etc/pam_ldap.conf`, which is the path compiled into the direct module. It no longer
|
||||
writes a second `/etc/ldap.conf`.
|
||||
|
||||
PADL's verification directive is `tls_checkpeer yes`, not `tls_reqcert`. Its CA-file directive
|
||||
is `tls_cacertfile`; this image points it to `/etc/ssl/certs/ca-certificates.crt`, installed by
|
||||
`ca-certificates`. No private CA file is required for the lab's Let's Encrypt certificates.
|
||||
Endpoints must still provide a valid full chain for their hostnames.
|
||||
|
||||
`ldaps://` endpoints use LDAPS. `ldap://` endpoints explicitly use StartTLS before binding.
|
||||
Mixing those transports in one endpoint list is rejected rather than silently selecting an
|
||||
incompatible mode or permitting an unencrypted fallback.
|
||||
|
||||
The MariaDB PAM helper remains setuid root so it can read the bind credential. The build checks
|
||||
the MariaDB plugin/helper and direct module dependencies, restricts the helper directory to
|
||||
root and mysql, and verifies mysql can execute the helper. Do not enable `no-new-privileges`
|
||||
for this backend container: it would prevent that elevation. Startup now reports this
|
||||
configuration error explicitly instead of proceeding to generic access-denied failures.
|
||||
|
||||
The privilege check uses `setpriv --dump` from `util-linux`, which queries the kernel's
|
||||
`PR_GET_NO_NEW_PRIVS` interface. It does not depend on a `NoNewPrivs` line in process status:
|
||||
that field only appeared in Linux 4.10, whereas the kernel query exists since 3.5. This matters on
|
||||
older Synology kernels. A confirmed flag value of 1 is still rejected; an unavailable or malformed
|
||||
query fails explicitly rather than silently assuming privilege elevation will work.
|
||||
|
||||
The stock MariaDB entrypoint, certificate group handling, data directory, replication settings
|
||||
and database healthcheck remain in use. This change does not create accounts, change passwords,
|
||||
reseed databases, or alter database ownership.
|
||||
|
||||
## Build and rollout
|
||||
|
||||
The default engine version is explicitly `13.0.2`, matching the running primary and replica
|
||||
observed during this incident. The old custom tag `11.8` actually contained MariaDB 13.0.2;
|
||||
do not deploy an actual 11.8 binary against the restored 13.0 data.
|
||||
|
||||
From this image directory, the owner can build a distinctly named image:
|
||||
|
||||
```sh
|
||||
docker build -f Containerfile -t containers.kritikos.io/system/mariadb-extended:13.0.2-pam-direct .
|
||||
```
|
||||
|
||||
Publish and pin the resulting image digest through the normal registry workflow. The assistant
|
||||
does not build, push or deploy images to the lab.
|
||||
|
||||
Deploy to zagreus first, retaining its data mount, server ID, read-only setting, replication
|
||||
configuration, full-chain certificate and actual `CERTS_GID`. Check a direct AD-authenticated
|
||||
MariaDB connection to zagreus before changing Plutus. Then update only Plutus's MariaDB container;
|
||||
that briefly disconnects primary clients, but requires no NAS reboot and no PostgreSQL restart.
|
||||
|
||||
Verify on each updated backend:
|
||||
|
||||
```sh
|
||||
docker exec mariadb mariadbd --version
|
||||
docker exec mariadb cat /etc/pam.d/mariadb
|
||||
docker exec mariadb ldd /usr/local/lib/security/pam_ldap_direct.so
|
||||
```
|
||||
|
||||
The PAM service must name the explicit direct module, its dependencies must resolve, and the
|
||||
engine must remain 13.0.2. Do not print `/etc/pam_ldap.conf`; it contains the directory bind password.
|
||||
|
||||
Then, from a client, use a real directory user's password with verified TLS:
|
||||
|
||||
```sh
|
||||
mariadb --no-defaults --protocol=TCP --host=zagreus.ad.kritikos.io --port=3306 --user=kritikos --password --ssl --ssl-verify-server-cert --database=grimmory --execute='SELECT CURRENT_USER(), CURRENT_ROLE(), @@server_id;'
|
||||
```
|
||||
|
||||
Repeat against plutus and finally `mariadb.kritikos.io` through MaxScale. A root or monitoring
|
||||
account using native-password authentication does not validate PAM. Check replica status and
|
||||
ordinary database health as well; this image does not change the existing replication channel.
|
||||
|
||||
## Validation scope
|
||||
|
||||
The pinned source was compiled and dynamically loaded locally under WSL with available PAM and
|
||||
OpenLDAP headers. The compiled module exported `pam_sm_authenticate` and `pam_sm_acct_mgmt`,
|
||||
linked directly to `libldap`, and detected both `ldap_initialize` and StartTLS support.
|
||||
That was a local build, not execution inside the target Ubuntu image or against AD.
|
||||
|
||||
[tests/test_pam_image.py](tests/test_pam_image.py) checks the pinned image recipe and runs a
|
||||
rendered entrypoint in temporary directories with stubbed identity/group/stock-entrypoint commands.
|
||||
It exercises TLS modes, configuration generation, credential redaction, missing components,
|
||||
privilege constraints and argument forwarding without contacting hosts:
|
||||
|
||||
```sh
|
||||
python3 -m unittest discover -s tests -v
|
||||
```
|
||||
|
||||
Entrypoint execution tests need a POSIX environment. The assistant has not built or deployed the
|
||||
Docker image. On 2026-10-03, the owner verified the `kritikos` PAM/AD login directly against both
|
||||
backends over verified TLS, with the expected aggregate role and primary/replica server IDs.
|
||||
|
||||
The later successful read through MaxScale required a separate site configuration correction on
|
||||
zagreus: `bind_address = *`, providing both IPv4 and IPv6 listeners. Its IPv4-only listener did not
|
||||
cover the IPv6 addresses advertised by its hostname. This was a backend listener change, not an
|
||||
LDAP password, grant or TLS-verification change.
|
||||
|
||||
A later controlled test wrote one row through MaxScale to Plutus (server ID 1, read-only OFF)
|
||||
and read the same row directly on zagreus (server ID 2, read-only ON), retaining the recorded
|
||||
writer server ID 1. That verifies primary write routing and replication of a new row. It does not
|
||||
establish failover behavior or crash durability. Cleanup of the disposable probe table was not
|
||||
shown in those results.
|
||||
@@ -1,26 +1,64 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
set -f
|
||||
|
||||
# Render pam_ldap's config from the environment so the AD bind credentials are
|
||||
# never baked into the image. pam_ldap performs a search+bind against AD: bind
|
||||
# as the service account, find the user by ${LDAP_LOGIN_ATTR}, then re-bind as
|
||||
# that DN with the supplied password to verify it — the same shape as pgpool's
|
||||
# `ldap` method (see pool_hba.conf).
|
||||
: "${LDAPURI:?LDAPURI is required (e.g. ldaps://hecate.ad.kritikos.io)}"
|
||||
: "${LDAP_BASE:?LDAP_BASE is required (e.g. DC=ad,DC=kritikos,DC=io)}"
|
||||
: "${LDAPBINDDN:?LDAPBINDDN is required (AD service/bind account)}"
|
||||
: "${LDAPPASSWORD:?LDAPPASSWORD is required}"
|
||||
: "${LDAP_LOGIN_ATTR:?LDAP_LOGIN_ATTR is required (e.g. sAMAccountName)}"
|
||||
# demand = validate the AD cert against the trust store; relax to 'allow' only
|
||||
# for testing, or add your internal CA to the image and keep 'demand'.
|
||||
: "${LDAP_TLS_REQCERT:?LDAP_TLS_REQCERT is required (demand|allow|never)}"
|
||||
: "${LDAP_TLS_REQCERT:?LDAP_TLS_REQCERT is required (demand or hard)}"
|
||||
: "${CERTS_GID:?CERTS_GID is required (GID owning the host TLS key)}"
|
||||
|
||||
fail() {
|
||||
printf 'ldap-entrypoint: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
for setting in LDAPURI LDAP_BASE LDAPBINDDN LDAPPASSWORD LDAP_LOGIN_ATTR LDAP_TLS_REQCERT CERTS_GID; do
|
||||
[[ ${!setting} != *$'\n'* && ${!setting} != *$'\r'* ]] \
|
||||
|| fail "$setting must be a single line"
|
||||
done
|
||||
|
||||
case "${LDAP_TLS_REQCERT,,}" in
|
||||
demand|hard) ;;
|
||||
*) fail "LDAP_TLS_REQCERT must be demand or hard; LDAP certificate verification cannot be disabled" ;;
|
||||
esac
|
||||
|
||||
[[ $LDAP_LOGIN_ATTR =~ ^[A-Za-z][A-Za-z0-9-]*$ ]] \
|
||||
|| fail "LDAP_LOGIN_ATTR must be an LDAP attribute name"
|
||||
[[ $CERTS_GID =~ ^[0-9]+$ ]] || fail "CERTS_GID must be numeric"
|
||||
|
||||
tls_mode=''
|
||||
for uri in $LDAPURI; do
|
||||
case "$uri" in
|
||||
ldaps://*) mode=on ;;
|
||||
ldap://*) mode=start_tls ;;
|
||||
*) fail "LDAPURI must use ldaps:// or ldap:// with StartTLS" ;;
|
||||
esac
|
||||
[[ -z $tls_mode || $tls_mode == "$mode" ]] \
|
||||
|| fail "LDAPURI cannot mix LDAPS and StartTLS endpoints"
|
||||
tls_mode=$mode
|
||||
done
|
||||
[[ -n $tls_mode ]] || fail "LDAPURI must contain an endpoint"
|
||||
|
||||
module=/usr/local/lib/security/pam_ldap_direct.so
|
||||
helper=/usr/lib/mysql/plugin/auth_pam_tool_dir/auth_pam_tool
|
||||
ca_file=/etc/ssl/certs/ca-certificates.crt
|
||||
[[ -r $module ]] || fail "direct LDAP PAM module is missing: $module"
|
||||
[[ -x $helper && -u $helper ]] || fail "MariaDB's setuid PAM helper is unavailable"
|
||||
[[ -r $ca_file ]] || fail "system CA bundle is missing: $ca_file"
|
||||
privileges=$(LC_ALL=C setpriv --dump) \
|
||||
|| fail "cannot inspect the PAM helper privilege constraints"
|
||||
no_new_privs=$(printf '%s\n' "$privileges" | sed -n 's/^no_new_privs:[[:space:]]*//p')
|
||||
case "$no_new_privs" in
|
||||
0) ;;
|
||||
1) fail "no-new-privileges prevents MariaDB's PAM helper from reading its root-only configuration" ;;
|
||||
*) fail "cannot determine the PAM helper privilege constraints" ;;
|
||||
esac
|
||||
|
||||
umask 077
|
||||
# pam_ldap.so on this image reads /etc/ldap.conf (the PADL default path), and
|
||||
# libpam-ldap ships a placeholder there (base dc=example,dc=net) that otherwise
|
||||
# shadows our config and makes every login fail. Render both paths.
|
||||
for conf in /etc/ldap.conf /etc/pam_ldap.conf; do
|
||||
cat > "$conf" <<EOF
|
||||
cat > /etc/pam_ldap.conf <<EOF
|
||||
uri ${LDAPURI}
|
||||
base ${LDAP_BASE}
|
||||
binddn ${LDAPBINDDN}
|
||||
@@ -29,19 +67,12 @@ scope sub
|
||||
referrals no
|
||||
pam_login_attribute ${LDAP_LOGIN_ATTR}
|
||||
pam_filter objectclass=User
|
||||
tls_cacertdir /etc/ssl/certs
|
||||
tls_reqcert ${LDAP_TLS_REQCERT}
|
||||
ssl ${tls_mode}
|
||||
tls_cacertfile ${ca_file}
|
||||
tls_checkpeer yes
|
||||
EOF
|
||||
chmod 600 "$conf"
|
||||
done
|
||||
chmod 600 /etc/pam_ldap.conf
|
||||
|
||||
# Grant mysql read access to the host TLS key (mode 0640, owned by GID
|
||||
# ${CERTS_GID}). compose exposes that GID via `group_add`, but the stock
|
||||
# entrypoint drops root -> mysql with gosu, which rebuilds mysql's groups from
|
||||
# /etc/group and discards the compose-added GID — mysql then can't read ssl_key
|
||||
# and MariaDB aborts with "Unable to get private key". While still root, put
|
||||
# mysql in a group with that GID so it survives the privilege drop.
|
||||
: "${CERTS_GID:?CERTS_GID is required (GID owning the host TLS key)}"
|
||||
if [ "$(id -u)" = '0' ]; then
|
||||
if getent group "${CERTS_GID}" >/dev/null; then
|
||||
certgroup="$(getent group "${CERTS_GID}" | cut -d: -f1)"
|
||||
|
||||
@@ -1,6 +1,2 @@
|
||||
# PAM service for MariaDB auth_pam logins (accounts created IDENTIFIED VIA pam
|
||||
# USING 'mariadb'). Authenticate + authorize against Active Directory through
|
||||
# the daemonless pam_ldap module; its connection settings live in
|
||||
# /etc/pam_ldap.conf, rendered at container start from the environment.
|
||||
auth required pam_ldap.so
|
||||
account required pam_ldap.so
|
||||
auth required /usr/local/lib/security/pam_ldap_direct.so
|
||||
account required /usr/local/lib/security/pam_ldap_direct.so
|
||||
@@ -0,0 +1,247 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
MODULE = "/usr/local/lib/security/pam_ldap_direct.so"
|
||||
HELPER = "/usr/lib/mysql/plugin/auth_pam_tool_dir/auth_pam_tool"
|
||||
CONF = "/etc/pam_ldap.conf"
|
||||
CA = "/etc/ssl/certs/ca-certificates.crt"
|
||||
|
||||
|
||||
class ImageRecipeTests(unittest.TestCase):
|
||||
def test_linux_entrypoint_line_endings_are_preserved(self):
|
||||
self.assertIn("docker-entrypoint.sh text eol=lf", (ROOT / ".gitattributes").read_text())
|
||||
self.assertNotIn(b"\r", (ROOT / "docker-entrypoint.sh").read_bytes())
|
||||
|
||||
def test_source_is_pinned_and_checked_before_build(self):
|
||||
recipe = (ROOT / "Containerfile").read_text()
|
||||
self.assertIn("ARG MARIADB_VERSION=13.0.2", recipe)
|
||||
self.assertIn("656448f091cbeb9efb3ece08e6868e40b8e7b6f8", recipe)
|
||||
self.assertIn("1aa622e85621b9018025e3483956d854566a5bc0636a4e8d9d005093f02dd16b", recipe)
|
||||
self.assertLess(recipe.index("sha256sum -c -"), recipe.index("./configure"))
|
||||
self.assertIn("--with-ldap-conf-file=/etc/pam_ldap.conf", recipe)
|
||||
self.assertIn("PERL5LIB=/build/pam_ldap make", recipe)
|
||||
|
||||
def test_runtime_does_not_install_the_virtual_ldap_package(self):
|
||||
recipe = (ROOT / "Containerfile").read_text()
|
||||
self.assertNotIn("libpam-ldap", recipe)
|
||||
self.assertNotIn("nslcd", recipe)
|
||||
self.assertNotIn("mariadb-plugin-auth-pam || true", recipe)
|
||||
self.assertIn("libldap2", recipe)
|
||||
self.assertIn("util-linux", recipe)
|
||||
self.assertIn("command -v setpriv", recipe)
|
||||
self.assertIn("ldd \"$module\"", recipe)
|
||||
self.assertIn("not found", recipe)
|
||||
self.assertIn("chmod 4750", recipe)
|
||||
self.assertIn("gosu mysql test -x", recipe)
|
||||
|
||||
def test_pam_stack_selects_the_exact_direct_module(self):
|
||||
stack = (ROOT / "pam.d" / "mariadb").read_text().splitlines()
|
||||
self.assertEqual(
|
||||
[["auth", "required", MODULE], ["account", "required", MODULE]],
|
||||
[line.split() for line in stack],
|
||||
)
|
||||
|
||||
def test_stock_database_lifecycle_is_preserved(self):
|
||||
recipe = (ROOT / "Containerfile").read_text()
|
||||
entrypoint = (ROOT / "docker-entrypoint.sh").read_text()
|
||||
self.assertIn('ENTRYPOINT ["ldap-entrypoint.sh"]', recipe)
|
||||
self.assertIn('CMD ["mariadbd"]', recipe)
|
||||
self.assertIn('exec docker-entrypoint.sh "$@"', entrypoint)
|
||||
self.assertIn("healthcheck.sh --connect --innodb_initialized", recipe)
|
||||
self.assertNotIn("nslcd", entrypoint)
|
||||
self.assertIn("LC_ALL=C setpriv --dump", entrypoint)
|
||||
self.assertNotIn("/proc/self/status", entrypoint)
|
||||
|
||||
|
||||
@unittest.skipUnless(os.name == "posix" and shutil.which("bash"), "Requires a local POSIX shell; no Docker or lab access.")
|
||||
class EntrypointTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory(prefix="mariadb-pam-entrypoint-")
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name)
|
||||
self.bin = self.root / "bin"
|
||||
self.bin.mkdir()
|
||||
self.log = self.root / "calls"
|
||||
self.conf = self.root / "pam_ldap.conf"
|
||||
self.module = self.root / "pam_ldap_direct.so"
|
||||
self.helper = self.root / "auth_pam_tool"
|
||||
self.ca = self.root / "ca.pem"
|
||||
self.status = self.root / "status"
|
||||
self.module.write_text("fixture-module")
|
||||
self.helper.write_text("fixture-helper")
|
||||
self.helper.chmod(0o4750)
|
||||
self.ca.write_text("fixture-ca")
|
||||
self.status.write_text("NoNewPrivs:\t0\n")
|
||||
text = (ROOT / "docker-entrypoint.sh").read_text()
|
||||
for old, new in (
|
||||
(MODULE, self.module), (HELPER, self.helper), (CONF, self.conf),
|
||||
(CA, self.ca), ("/proc/self/status", self.status),
|
||||
):
|
||||
text = text.replace(old, str(new))
|
||||
self.script = self.root / "entrypoint.sh"
|
||||
self.script.write_text(text)
|
||||
self.env = {
|
||||
**os.environ,
|
||||
"PATH": str(self.bin) + os.pathsep + os.environ["PATH"],
|
||||
"LDAPURI": "ldaps://directory.example.test",
|
||||
"LDAP_BASE": "DC=example,DC=test",
|
||||
"LDAPBINDDN": "CN=reader,DC=example,DC=test",
|
||||
"LDAPPASSWORD": "fixture-password-not-for-logs",
|
||||
"LDAP_LOGIN_ATTR": "sAMAccountName",
|
||||
"LDAP_TLS_REQCERT": "demand",
|
||||
"CERTS_GID": "12002",
|
||||
"TEST_CALLS": str(self.log),
|
||||
"TEST_HAVE_GROUP": "1",
|
||||
"TEST_UID": "0",
|
||||
"TEST_NO_NEW_PRIVS": "0",
|
||||
"TEST_SETPRIV_RC": "0",
|
||||
}
|
||||
self.stub("id", 'printf "%s\\n" "$TEST_UID"\n')
|
||||
self.stub("getent", 'if [ "$TEST_HAVE_GROUP" = 1 ]; then printf "certgroup:x:%s:\\n" "$2"; else exit 2; fi\n')
|
||||
self.stub("groupadd", 'printf "groupadd %s\\n" "$*" >> "$TEST_CALLS"\n')
|
||||
self.stub("usermod", 'printf "usermod %s\\n" "$*" >> "$TEST_CALLS"\n')
|
||||
self.stub(
|
||||
"setpriv",
|
||||
'[ "$1" = --dump ] || exit 9\n'
|
||||
'[ "$TEST_SETPRIV_RC" = 0 ] || exit "$TEST_SETPRIV_RC"\n'
|
||||
'printf "uid: 999\\nno_new_privs: %s\\n" "$TEST_NO_NEW_PRIVS"\n',
|
||||
)
|
||||
self.stub("docker-entrypoint.sh", 'printf "delegate %s\\n" "$*" >> "$TEST_CALLS"\n')
|
||||
|
||||
def stub(self, name, body):
|
||||
path = self.bin / name
|
||||
path.write_text("#!/bin/sh\nset -eu\n" + body)
|
||||
path.chmod(0o755)
|
||||
|
||||
def run_entrypoint(self, expected=0):
|
||||
result = subprocess.run(
|
||||
["bash", str(self.script), "mariadbd", "--server-id=2", "--read-only=ON"],
|
||||
env=self.env, text=True, capture_output=True, timeout=5,
|
||||
)
|
||||
self.assertEqual(expected, result.returncode, result.stdout + result.stderr)
|
||||
self.assertNotIn(self.env.get("LDAPPASSWORD", "unused-secret"), result.stdout + result.stderr)
|
||||
return result
|
||||
|
||||
def test_ldaps_writes_only_the_direct_module_configuration(self):
|
||||
self.run_entrypoint()
|
||||
text = self.conf.read_text()
|
||||
self.assertIn("uri ldaps://directory.example.test\n", text)
|
||||
self.assertIn("ssl on\n", text)
|
||||
self.assertIn("tls_checkpeer yes\n", text)
|
||||
self.assertIn(f"tls_cacertfile {self.ca}\n", text)
|
||||
self.assertIn("pam_login_attribute sAMAccountName\n", text)
|
||||
self.assertIn("bindpw fixture-password-not-for-logs\n", text)
|
||||
self.assertNotIn("tls_reqcert", text)
|
||||
self.assertEqual(0o600, stat.S_IMODE(self.conf.stat().st_mode))
|
||||
self.assertIn("usermod -aG certgroup mysql", self.log.read_text())
|
||||
self.assertIn("delegate mariadbd --server-id=2 --read-only=ON", self.log.read_text())
|
||||
|
||||
def test_ldap_uses_starttls_not_plain_bind(self):
|
||||
self.env["LDAPURI"] = "ldap://directory.example.test:389"
|
||||
self.run_entrypoint()
|
||||
self.assertIn("ssl start_tls\n", self.conf.read_text())
|
||||
self.assertIn("tls_checkpeer yes\n", self.conf.read_text())
|
||||
|
||||
def test_multiple_same_transport_uris_are_supported(self):
|
||||
self.env["LDAPURI"] = "ldaps://one.example.test ldaps://two.example.test"
|
||||
self.run_entrypoint()
|
||||
self.assertIn("uri " + self.env["LDAPURI"], self.conf.read_text())
|
||||
|
||||
def test_mixed_transports_are_rejected(self):
|
||||
self.env["LDAPURI"] = "ldaps://one.example.test ldap://two.example.test"
|
||||
result = self.run_entrypoint(1)
|
||||
self.assertIn("cannot mix", result.stderr)
|
||||
self.assertFalse(self.log.exists())
|
||||
|
||||
def test_weak_tls_verification_is_rejected(self):
|
||||
for value in ("allow", "try", "never"):
|
||||
with self.subTest(value=value):
|
||||
self.env["LDAP_TLS_REQCERT"] = value
|
||||
self.run_entrypoint(1)
|
||||
self.assertFalse(self.log.exists())
|
||||
|
||||
def test_hard_is_an_alias_for_verified_tls(self):
|
||||
self.env["LDAP_TLS_REQCERT"] = "hard"
|
||||
self.run_entrypoint()
|
||||
self.assertIn("tls_checkpeer yes", self.conf.read_text())
|
||||
|
||||
def test_missing_secret_is_not_defaulted(self):
|
||||
del self.env["LDAPPASSWORD"]
|
||||
result = self.run_entrypoint(1)
|
||||
self.assertIn("LDAPPASSWORD is required", result.stderr)
|
||||
self.assertFalse(self.conf.exists())
|
||||
|
||||
def test_newline_injection_fails_without_logging_the_secret(self):
|
||||
self.env["LDAPPASSWORD"] = "fixture-password-not-for-logs\ntls_checkpeer no"
|
||||
result = self.run_entrypoint(1)
|
||||
self.assertIn("must be a single line", result.stderr)
|
||||
self.assertNotIn("fixture-password-not-for-logs", result.stderr)
|
||||
self.assertFalse(self.conf.exists())
|
||||
|
||||
def test_missing_or_non_setuid_helper_is_reported_before_start(self):
|
||||
self.helper.chmod(0o0750)
|
||||
result = self.run_entrypoint(1)
|
||||
self.assertIn("setuid PAM helper", result.stderr)
|
||||
self.assertFalse(self.log.exists())
|
||||
|
||||
def test_missing_module_is_reported_before_start(self):
|
||||
self.module.unlink()
|
||||
result = self.run_entrypoint(1)
|
||||
self.assertIn("direct LDAP PAM module is missing", result.stderr)
|
||||
self.assertFalse(self.conf.exists())
|
||||
|
||||
def test_missing_trust_store_fails_closed(self):
|
||||
self.ca.unlink()
|
||||
result = self.run_entrypoint(1)
|
||||
self.assertIn("system CA bundle", result.stderr)
|
||||
self.assertFalse(self.log.exists())
|
||||
|
||||
def test_no_new_privileges_is_reported_before_start(self):
|
||||
self.status.write_text("NoNewPrivs:\t1\n")
|
||||
self.env["TEST_NO_NEW_PRIVS"] = "1"
|
||||
result = self.run_entrypoint(1)
|
||||
self.assertIn("no-new-privileges", result.stderr)
|
||||
self.assertFalse(self.conf.exists())
|
||||
|
||||
def test_missing_proc_status_field_does_not_block_startup(self):
|
||||
self.status.write_text("Name:\tmariadbd\nUid:\t999\t999\t999\t999\n")
|
||||
self.run_entrypoint()
|
||||
self.assertIn("delegate mariadbd", self.log.read_text())
|
||||
|
||||
def test_failed_privilege_query_is_not_ignored(self):
|
||||
self.env["TEST_SETPRIV_RC"] = "1"
|
||||
result = self.run_entrypoint(1)
|
||||
self.assertIn("privilege constraints", result.stderr)
|
||||
self.assertFalse(self.conf.exists())
|
||||
|
||||
def test_malformed_privilege_query_is_not_ignored(self):
|
||||
self.env["TEST_NO_NEW_PRIVS"] = "unknown"
|
||||
result = self.run_entrypoint(1)
|
||||
self.assertIn("cannot determine", result.stderr)
|
||||
self.assertFalse(self.conf.exists())
|
||||
|
||||
def test_certificate_group_is_created_when_missing(self):
|
||||
self.env["TEST_HAVE_GROUP"] = "0"
|
||||
self.run_entrypoint()
|
||||
self.assertIn("groupadd -g 12002 hostcerts", self.log.read_text())
|
||||
self.assertIn("usermod -aG hostcerts mysql", self.log.read_text())
|
||||
|
||||
def test_invalid_login_attribute_and_gid_are_rejected(self):
|
||||
for key, value in (("LDAP_LOGIN_ATTR", "uid)(objectClass)=*"), ("CERTS_GID", "not-a-gid")):
|
||||
with self.subTest(key=key):
|
||||
previous = self.env[key]
|
||||
self.env[key] = value
|
||||
self.run_entrypoint(1)
|
||||
self.env[key] = previous
|
||||
self.assertFalse(self.log.exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user