commit 5744f4b7db717fbf22f7cd40d73675ff0e2d71fc Author: Alexandros Kritikos Date: Sat Aug 15 19:57:25 2026 +0300 ✨ Commits existing images to git - Implemented sync-roles.sh to synchronize Active Directory group memberships as MariaDB roles and user accounts. - Created Containerfile for building a custom ldap2pg image with patched YAML decoding. - Added provision-user-db.sh for provisioning user databases and roles based on LDAP synchronization. - Introduced a new Containerfile for MariaDB with PAM authentication via pam_ldap. - Configured PAM service for MariaDB logins and added necessary plugins for LDAP authentication. - Implemented health checks and entrypoint scripts for MariaDB and pgpool containers. - Enhanced PostgreSQL image with additional extensions and custom pg_hba.conf for improved security and functionality. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2073017 --- /dev/null +++ b/README.md @@ -0,0 +1,89 @@ +# images + +Container images published to `containers.kritikos.io`, built with Docker Compose. + +```powershell +docker compose build # all images +docker compose build postgres # one image +``` + +Every version is a build arg supplied from [database/.env](database/.env); see +[database/compose.yaml](database/compose.yaml) for the wiring. + +## Alpine version vs. PostgreSQL major + +`ALPINE_VERSION` and `PG_MAJOR` are not independent. The Alpine-based images +(`pgpool`, `ldap2pg`, `ldap2mariadb`) install PostgreSQL packages with +`apk add postgresql${PG_MAJOR}-dev` / `-client`, and an Alpine image's `apk` +repositories are fixed to that release's branch. Each branch packages only a +subset of PostgreSQL majors, and that subset changes every release: + +| Alpine branch | `postgresql*-dev` available | +| ------------- | ----------------------------- | +| edge | 18, 17, 16 | +| v3.24 | 18, 17 (main), 16 (community) | +| v3.23 | 18, 17 (main), 16 (community) | +| v3.22 | 17, 16 (main), 15 (community) | + +_Measured with `apk policy` inside each branch's image on 2026-08-15._ + +So `PG_MAJOR=18` requires Alpine 3.23 or newer. The coupling breaks in both +directions: + +- Raising `PG_MAJOR` before Alpine's stable branch packages that major fails the + build — a September PostgreSQL release will not reach a stable Alpine until + that branch ships. +- Leaving `PG_MAJOR` behind while Alpine advances fails eventually too: ageing + majors are demoted from `main` to `community`, then dropped. 16 has already + fallen to community on v3.23, and 15 is gone from v3.23 onwards. + +**Bump `ALPINE_VERSION` and `PG_MAJOR` together**, and confirm the target branch +carries `postgresql${PG_MAJOR}-dev` before doing so. + +Pinning matters because `alpine:latest` is a mutable tag that follows Alpine's +roughly six-monthly releases, so the same commit can build against a different +base OS and a different package set on a later day. Note that `--no-cache` on +`apk add` does not pin anything — it only avoids a stale index, and package +versions still float within the branch. + +`pgpool` is the image most exposed to this, since it is the one that *compiles* +against `postgresql${PG_MAJOR}-dev` rather than just installing a client. All +three Alpine-based images (`pgpool`, `ldap2pg`, `ldap2mariadb`) take +`ALPINE_VERSION` from `database/.env`, currently pinned to `3.23`. + +## pgpool health check + +pgpool ships no health endpoint of its own. Its `health_check_period` settings +govern how pgpool probes *its backends* to drive failover, and the PCP tools +(`pcp_node_info` and friends) need a `pcp.conf` user plus a password file, which +is more than a container probe should carry. The image therefore probes with +`pg_isready` against the pgpool port. + +What that reports, measured against two live backends: + +| Backend state | Health | +| --------------------------------- | ------ | +| all nodes up | green | +| standby down, degenerated | green | +| primary down, standby promoted | green | +| during failover (~one check cycle)| red | +| no node attached, or no primary | red | + +So green means **pgpool can serve**, not that the cluster is healthy. Losing a +standby is invisible to it, and because pgpool keeps a node `down` until +`pcp_attach_node` is run, the probe stays green after a backend recovers while +the pool is still running on one node. Track node-level state with +`SHOW POOL_NODES` instead. + +Two consequences for compose files consuming this image: + +- `depends_on: condition: service_healthy` is the intended use — it stops + dependents from starting against a pool that has no primary. +- Do not restart pgpool on unhealthy. Bouncing it during a backend outage resets + its node status and drops the pool without fixing anything. + +In a real streaming-replication cluster the survivor of a primary loss is in +recovery, so `find_primary_node` reports nothing until `failover_command` +promotes it; connections fail with `failed to create a backend connection` and +the probe goes red for that whole window. The check is therefore gated on +failover automation working, not merely on a node being up. diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..dcb4d0c --- /dev/null +++ b/compose.yaml @@ -0,0 +1,2 @@ +include: + - database/compose.yaml \ No newline at end of file diff --git a/database/.env b/database/.env new file mode 100644 index 0000000..83cc8a6 --- /dev/null +++ b/database/.env @@ -0,0 +1,10 @@ +ALPINE_VERSION=3.24 +GO_VERSION=1.26.3 + +PG_MAJOR=18 +POSTGIS_MAJOR=3 +VCHORD_VERSION=1.1.1 +PGPOOL_VERSION=4.7.2 +LDAP2PG_VERSION=6.6.0 + +MARIADB_VERSION=12 \ No newline at end of file diff --git a/database/compose.yaml b/database/compose.yaml new file mode 100644 index 0000000..1edeb07 --- /dev/null +++ b/database/compose.yaml @@ -0,0 +1,50 @@ +name: image-builder + +services: + postgres: + image: containers.kritikos.io/system/postgres-extended:${PG_MAJOR:-18} + build: + context: postgres + dockerfile: Containerfile + args: + - PG_MAJOR=${PG_MAJOR:-18} + - POSTGIS_MAJOR=${POSTGIS_MAJOR:-3} + - VCHORD_VERSION=${VCHORD_VERSION:-1.1.1} + + ldap2pg: + image: containers.kritikos.io/system/ldap2pg:${LDAP2PG_VERSION:-6.6.0} + build: + context: ldap2pg + dockerfile: Containerfile + args: + - ALPINE=${ALPINE_VERSION:-latest} + - GO_VERSION=${GO_VERSION:-1.26.3} + - LDAP2PG_VERSION=${LDAP2PG_VERSION:-6.6.0} + + pgpool: + container_name: pgpool + image: containers.kritikos.io/system/pgpool:${PGPOOL_VERSION:-4.7.2} + build: + context: pgpool + dockerfile: Containerfile + args: + - ALPINE=${ALPINE_VERSION:-latest} + - PG_MAJOR=${PG_MAJOR:-18} + - PGPOOL_VERSION=${PGPOOL_VERSION:-4.7.2} + + mariadb: + image: containers.kritikos.io/system/mariadb-extended:${MARIADB_VERSION:-12} + build: + context: mariadb + dockerfile: Containerfile + args: + - MARIADB_VERSION=${MARIADB_VERSION:-12} + + ldap2mariadb: + container_name: ldap2mariadb + image: containers.kritikos.io/system/ldap2mariadb:latest + build: + context: ldap2mariadb + dockerfile: Containerfile + args: + - ALPINE=${ALPINE_VERSION:-latest} diff --git a/database/ldap2mariadb/Containerfile b/database/ldap2mariadb/Containerfile new file mode 100644 index 0000000..e3f8b5f --- /dev/null +++ b/database/ldap2mariadb/Containerfile @@ -0,0 +1,16 @@ +ARG ALPINE=latest +FROM alpine:${ALPINE} + +RUN apk add --no-cache \ + bash \ + openldap-clients \ + mariadb-client \ + ca-certificates \ + && update-ca-certificates + +WORKDIR /workspace +COPY --chmod=0755 sync-roles.sh /workspace/sync-roles.sh +COPY --chmod=0755 provision-user-db.sh /workspace/provision-user-db.sh + +ENTRYPOINT ["/bin/bash"] +CMD ["sync-roles.sh"] \ No newline at end of file diff --git a/database/ldap2mariadb/provision-user-db.sh b/database/ldap2mariadb/provision-user-db.sh new file mode 100644 index 0000000..5b49402 --- /dev/null +++ b/database/ldap2mariadb/provision-user-db.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ───────────────────────────────────────────────────────────────────────────── +# provision-user-db.sh — MariaDB port of the postgres provisioner. +# +# For every PAM login account created by sync-roles.sh: create a personal +# database, make the user its de-facto owner, and grant the anchor roles +# (readers/writers/ddl) the right level of access on it. Then apply the same for +# per-database scoped roles (_readers | _writers | _ddl). +# +# MariaDB makes this simpler than postgres: +# * database == schema, and GRANT ... ON `db`.* already covers FUTURE tables, +# so there is no ALTER DEFAULT PRIVILEGES loop to maintain. +# * there is no PUBLIC pseudo-role and no per-database owner to fix, so the +# "revoke PUBLIC" and "fix owner" steps simply disappear. +# ───────────────────────────────────────────────────────────────────────────── + +: "${READERS_ROLE:?}" +: "${WRITERS_ROLE:?}" +: "${DDL_ROLE:?}" + +: "${MYSQL_HOST:?}"; : "${MYSQL_PORT:?}"; : "${MYSQL_USER:?}" +MARIADB=(mariadb --host "${MYSQL_HOST}" --port "${MYSQL_PORT}" + --user "${MYSQL_USER}" --batch --skip-column-names) +sql() { "${MARIADB[@]}" -e "$1"; } +# Escaping is context-dependent: in a '...' literal a backslash escapes the next +# character (default sql_mode), while in a `...` identifier it is literal. +esc_str() { printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e "s/'/''/g"; } +esc_id() { printf '%s' "$1" | sed 's/`/``/g'; } + +grant_db_to_roles() { + local db; db="$(esc_id "$1")" + sql "GRANT SELECT ON \`${db}\`.* TO \`$(esc_id "$READERS_ROLE")\`;" + sql "GRANT SELECT, INSERT, UPDATE, DELETE ON \`${db}\`.* TO \`$(esc_id "$WRITERS_ROLE")\`;" + sql "GRANT ALL PRIVILEGES ON \`${db}\`.* TO \`$(esc_id "$DDL_ROLE")\`;" +} + +# ── 1. Per-user personal databases ────────────────────────────────────────── +echo "[*] Discovering PAM login accounts" +users="$(sql "SELECT User FROM mysql.user WHERE plugin='pam' AND User <> '' ORDER BY User;")" + +if [ -z "$users" ]; then + echo "[*] No PAM users found; nothing to provision." +else + while IFS= read -r u; do + [ -z "$u" ] && continue + edb="$(esc_id "$u")"; sdb="$(esc_str "$u")" + echo "[*] Provisioning DB for ${u}" + sql "CREATE DATABASE IF NOT EXISTS \`${edb}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_uca1400_ai_ci;" + # Owner-equivalent: full rights on their own DB, with grant option. + sql "GRANT ALL PRIVILEGES ON \`${edb}\`.* TO '${sdb}'@'%' WITH GRANT OPTION;" + grant_db_to_roles "$u" + done <<< "$users" +fi + +# ── 2. Per-database scoped roles (_readers | _writers | _ddl) ──────────── +echo "[*] Applying per-database scoped role grants" +perdb="$(sql " + SELECT User FROM mysql.user + WHERE is_role='Y' AND User REGEXP '_(readers|writers|ddl)\$' + ORDER BY User;")" + +while IFS= read -r role; do + [ -z "$role" ] && continue + db="${role%_*}" # strip the trailing _readers/_writers/_ddl + kind="${role##*_}" # readers | writers | ddl + exists="$(sql "SELECT SCHEMA_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME='$(esc_str "$db")';")" + [ -z "$exists" ] && { echo " - skip ${role} (no database '${db}')"; continue; } + edb="$(esc_id "$db")"; erole="$(esc_id "$role")" + echo " - ${kind} on \`${db}\` -> ${role}" + case "$kind" in + readers) sql "GRANT SELECT ON \`${edb}\`.* TO \`${erole}\`;" ;; + writers) sql "GRANT SELECT, INSERT, UPDATE, DELETE ON \`${edb}\`.* TO \`${erole}\`;" ;; + ddl) sql "GRANT ALL PRIVILEGES ON \`${edb}\`.* TO \`${erole}\`;" ;; + esac +done <<< "$perdb" + +echo "[*] Done." diff --git a/database/ldap2mariadb/sync-roles.sh b/database/ldap2mariadb/sync-roles.sh new file mode 100644 index 0000000..a543fd1 --- /dev/null +++ b/database/ldap2mariadb/sync-roles.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ───────────────────────────────────────────────────────────────────────────── +# sync-roles.sh — the ldap2pg analogue for MariaDB. +# +# There is no ldap2pg for MariaDB, so this reproduces the parts of ldap2pg.yaml +# we rely on: read AD group membership and materialise it as MariaDB roles plus +# per-user login accounts. Human accounts authenticate via PAM -> AD (no +# password is stored in MariaDB); the anchor roles carry the actual privileges, +# which provision-user-db.sh grants per database. +# +# Model (mirrors ldap2pg.yaml): +# Global groups under OU=Global,OU=Database,OU=Groups: +# owners | readers | writers | ddl -> members get the matching role +# Per-DB groups under OU=Database,OU=Groups: +# _readers | _writers | _ddl -> scoped roles +# +# Those groups are engine-agnostic: they say WHAT privileges a member gets, not +# on which server. ENGINE_GROUP_DN is the orthogonal gate — a member is only +# provisioned here if they are also in that group, which is what keeps the +# shared role groups from creating MariaDB accounts for Postgres-only users. +# +# Connection: MYSQL_HOST/PORT/USER + MYSQL_PWD (read from the environment by the +# mariadb client, so the password never appears on the command line). Point this +# at the backend directly, NOT MaxScale — same as ldap2pg talking to the PG +# primary rather than through pgpool. +# ───────────────────────────────────────────────────────────────────────────── + +: "${LDAPURI:?}"; : "${LDAPBINDDN:?}"; : "${LDAPPASSWORD:?}" +: "${GLOBAL_BASE:?}" +: "${PERDB_BASE:?}" +# Set but allowed to be empty: empty disables the engine gate entirely. +: "${ENGINE_GROUP_DN?}" + +# LDAP_MATCHING_RULE_IN_CHAIN: matches through nested groups, so the engine gate +# can hold org groups (Daemons, DBAs) instead of only individual users. +ENGINE_FILTER="" +if [ -n "$ENGINE_GROUP_DN" ]; then + ENGINE_FILTER="(memberOf:1.2.840.113556.1.4.1941:=${ENGINE_GROUP_DN})" +fi + +: "${MYSQL_HOST:?}"; : "${MYSQL_PORT:?}"; : "${MYSQL_USER:?}" +MARIADB=(mariadb --host "${MYSQL_HOST}" --port "${MYSQL_PORT}" + --user "${MYSQL_USER}" --batch --skip-column-names) +sql() { "${MARIADB[@]}" -e "$1"; } + +# Escaping is context-dependent: in a '...' literal a backslash escapes the next +# character (default sql_mode), while in a `...` identifier it is literal. +esc_str() { printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e "s/'/''/g"; } +esc_id() { printf '%s' "$1" | sed 's/`/``/g'; } + +# Emit the sAMAccountName of every direct user member of the named AD group. +group_members() { + local base="$1" group="$2" dns + dns="$(ldapsearch -LLL -o ldif-wrap=no -H "$LDAPURI" \ + -D "$LDAPBINDDN" -w "$LDAPPASSWORD" \ + -b "$base" -s one "(&(objectClass=group)(cn=${group}))" member \ + | sed -n 's/^member: //p')" + [ -z "$dns" ] && return 0 + while IFS= read -r dn; do + [ -z "$dn" ] && continue + ldapsearch -LLL -o ldif-wrap=no -H "$LDAPURI" -D "$LDAPBINDDN" -w "$LDAPPASSWORD" \ + -b "$dn" -s base \ + "(&(objectCategory=person)(objectClass=user)(!(objectClass=computer))${ENGINE_FILTER})" \ + sAMAccountName 2>/dev/null | sed -n 's/^sAMAccountName: //p' + done <<< "$dns" +} + +ensure_role() { + local role; role="$(esc_id "$1")" + sql "CREATE ROLE IF NOT EXISTS \`${role}\`;" +} + +ensure_user_in_role() { + local user role + user="$(esc_str "$1")"; role="$(esc_id "$2")" + # PAM login account: the password is verified against AD, nothing is stored. + sql "CREATE USER IF NOT EXISTS '${user}'@'%' IDENTIFIED VIA pam USING 'mariadb';" + sql "GRANT \`${role}\` TO '${user}'@'%';" + # MariaDB roles are inactive until selected, and it allows only ONE default + # role per user (no ALL, unlike MySQL) — make this role active at login. + sql "SET DEFAULT ROLE \`${role}\` FOR '${user}'@'%';" +} + +echo "[*] Ensuring anchor roles" +for r in owners readers writers ddl; do ensure_role "$r"; done + +echo "[*] Syncing global AD groups -> roles" +for grp in owners readers writers ddl; do + while IFS= read -r sam; do + [ -z "$sam" ] && continue + sam="$(printf '%s' "$sam" | tr '[:upper:]' '[:lower:]')" + echo " - ${sam} -> ${grp}" + ensure_user_in_role "$sam" "$grp" + done < <(group_members "$GLOBAL_BASE" "$grp") +done + +echo "[*] Syncing per-database AD groups -> scoped roles" +perdb_groups="$(ldapsearch -LLL -o ldif-wrap=no -H "$LDAPURI" \ + -D "$LDAPBINDDN" -w "$LDAPPASSWORD" \ + -b "$PERDB_BASE" -s one \ + '(&(objectClass=group)(|(cn=*_readers)(cn=*_writers)(cn=*_ddl)))' cn \ + | sed -n 's/^cn: //p')" +while IFS= read -r grp; do + [ -z "$grp" ] && continue + role="$(printf '%s' "$grp" | tr '[:upper:]' '[:lower:]')" + ensure_role "$role" + while IFS= read -r sam; do + [ -z "$sam" ] && continue + sam="$(printf '%s' "$sam" | tr '[:upper:]' '[:lower:]')" + echo " - ${sam} -> ${role}" + ensure_user_in_role "$sam" "$role" + done < <(group_members "$PERDB_BASE" "$grp") +done <<< "$perdb_groups" + +echo "[*] Role sync complete." diff --git a/database/ldap2pg/Containerfile b/database/ldap2pg/Containerfile new file mode 100644 index 0000000..f184bf2 --- /dev/null +++ b/database/ldap2pg/Containerfile @@ -0,0 +1,61 @@ +# syntax=docker/dockerfile:1.7 + +ARG ALPINE=latest +ARG GO_VERSION=1.26.3 +ARG LDAP2PG_VERSION=6.6.0 + +# ─── Build stage ─────────────────────────────────────────────────────── +# ldap2pg 6.6.0 introduced strict YAML decoding but left the `joins` +# subsearch key half-renamed: the normalizer rewrites it to `subsearches` +# while the mapstructure tag on the target struct still says `joins`, so +# any config using the (documented) `joins:` key is rejected as +# "invalid keys: subsearches". +# +# The bug lives in a single struct tag; we grab the upstream source +# tarball for the pinned tag, patch that one line, and rebuild. +# Remove the whole builder stage and switch back to the released +# .apk once upstream ships the fix. +FROM golang:${GO_VERSION}-alpine AS builder +ARG LDAP2PG_VERSION + +RUN apk add --no-cache curl tar + +WORKDIR /src +RUN curl -fsSL "https://github.com/dalibo/ldap2pg/archive/refs/tags/v${LDAP2PG_VERSION}.tar.gz" \ + | tar -xz --strip-components=1 + +# Re-align the struct tag with the normalizer's canonical key name. +RUN grep -q 'mapstructure:"joins"' internal/ldap/search.go \ + && sed -i 's|mapstructure:"joins"|mapstructure:"subsearches"|' internal/ldap/search.go + +RUN CGO_ENABLED=0 go build -trimpath \ + -ldflags "-s -w -X github.com/dalibo/ldap2pg/v6/internal/cmd.Version=${LDAP2PG_VERSION}" \ + -o /out/ldap2pg . + +# ─── Runtime stage ───────────────────────────────────────────────────── +FROM alpine:${ALPINE} + +ARG LDAP2PG_VERSION + +ENV LANG=en_US.utf8 + +WORKDIR /workspace + +RUN mkdir -p /docker-entrypoint.d \ + && apk add --no-cache \ + bash \ + ca-certificates \ + openldap-clients \ + postgresql-client \ + && update-ca-certificates + +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 diff --git a/database/ldap2pg/provision-user-db.sh b/database/ldap2pg/provision-user-db.sh new file mode 100644 index 0000000..456c199 --- /dev/null +++ b/database/ldap2pg/provision-user-db.sh @@ -0,0 +1,203 @@ +#!/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 _readers / _writers / _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 _readers|_writers|_ddl +# and where the 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." diff --git a/database/mariadb/Containerfile b/database/mariadb/Containerfile new file mode 100644 index 0000000..fdb9f6a --- /dev/null +++ b/database/mariadb/Containerfile @@ -0,0 +1,56 @@ +# syntax=docker/dockerfile:1.7 +ARG MARIADB_VERSION=latest + +FROM mariadb:${MARIADB_VERSION} + +# ── 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/*; \ + plugin=/usr/lib/mysql/plugin/auth_pam.so; \ + [ -e "$plugin" ] || { echo "auth_pam: missing $plugin" >&2; exit 1; } + +# ── 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=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 + +ENTRYPOINT ["ldap-entrypoint.sh"] +CMD ["mariadbd"] diff --git a/database/mariadb/conf.d/10-plugins.cnf b/database/mariadb/conf.d/10-plugins.cnf new file mode 100644 index 0000000..1ae7cac --- /dev/null +++ b/database/mariadb/conf.d/10-plugins.cnf @@ -0,0 +1,11 @@ +# Image-owned plugin preload (analogue of the postgres image's +# shared_preload_libraries in 10-extensions.conf). auth_pam provides Active +# Directory logins via PAM -> pam_ldap. +[mariadb] +plugin_load_add = auth_pam + +# Make auth_pam use the mysql_clear_password protocol instead of the interactive +# "dialog" one. Required for MaxScale: its clear_pw_passthrough only speaks +# mysql_native_password / mysql_clear_password to backends, never dialog. The +# password is sent in cleartext, so TLS must be on (99-tls.cnf) — it is. +pam_use_cleartext_plugin = 1 diff --git a/database/mariadb/docker-entrypoint.sh b/database/mariadb/docker-entrypoint.sh new file mode 100644 index 0000000..07c4e91 --- /dev/null +++ b/database/mariadb/docker-entrypoint.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +set -euo pipefail + +# 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)}" + +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" < 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)" + else + groupadd -g "${CERTS_GID}" hostcerts + certgroup=hostcerts + fi + usermod -aG "$certgroup" mysql +else + echo "ldap-entrypoint: not root (uid $(id -u)); left group membership unchanged (CERTS_GID=${CERTS_GID})" >&2 +fi + +exec docker-entrypoint.sh "$@" diff --git a/database/mariadb/initdb.d/10-maxscale-users.sh b/database/mariadb/initdb.d/10-maxscale-users.sh new file mode 100644 index 0000000..bfd5068 --- /dev/null +++ b/database/mariadb/initdb.d/10-maxscale-users.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Runs once on first cluster init (empty datadir), the analogue of +# init_pgpool_user.sql. Creates the infrastructure accounts (MaxScale monitor + +# service, and the replication user) on the backend. All use native password +# auth (NOT pam) — they are service accounts, not human AD logins. Passwords +# come from the environment so nothing secret is committed to a .sql file, and +# the root password goes via MYSQL_PWD (read by the client) rather than -p so it +# never appears in the process list. + +: "${MARIADB_ROOT_PASSWORD:?}" +: "${MAXSCALE_MONITOR_PASSWORD:?}" +: "${MAXSCALE_SERVICE_PASSWORD:?}" +: "${MARIADB_REPL_PASSWORD:?}" + +MYSQL_PWD="${MARIADB_ROOT_PASSWORD}" mariadb --protocol=socket -uroot <&2; exit 1; }; \ + grep -qE '^[[:space:]]*trusted[[:space:]]*=' "$ctl" \ + || printf 'trusted = true\n' >> "$ctl"; \ + done + +## Layered configuration (applied to every newly initialized cluster). +## initdb seeds postgresql.conf from postgresql.conf.sample, so appending +## include_dir there pulls in our config regardless of the container command. +## +## Image-owned config lives under /usr/local/etc/postgresql (conf.d + pg_hba.conf); +## /etc/postgresql holds ONLY the user override dirs (conf.d, hba.d) so that +## path is a clean "mount your files here" area. +## +## postgresql.conf: our /usr/local/etc/postgresql/conf.d loaded first, user +## /etc/postgresql/conf.d last (last-wins => users override us, but a stray +## mount can't wipe our defaults e.g. shared_preload_libraries). +## +## pg_hba.conf: conf.d/20-hba.conf points hba_file at our authoritative +## /usr/local/etc/postgresql/pg_hba.conf, which fully replaces the initdb- +## generated file (and sidesteps the entrypoint's appended "host all all all +## " line, written to the now-unused PGDATA copy). Tier order in that +## file: local allows -> include_dir /etc/postgresql/hba.d -> default-deny. +COPY conf.d/ /usr/local/etc/postgresql/conf.d/ +COPY pg_hba.conf /usr/local/etc/postgresql/pg_hba.conf +RUN mkdir -p /etc/postgresql/conf.d \ + /etc/postgresql/hba.d \ + && { \ + echo "include_dir = '/usr/local/etc/postgresql/conf.d'"; \ + echo "include_dir = '/etc/postgresql/conf.d'"; \ + } >> /usr/share/postgresql/${PG_MAJOR}/postgresql.conf.sample + +## Clean image +RUN rm -rf /var/lib/apt/lists/* \ + && rm -rf /tmp/* + +## Report health via pg_isready (env vars expand at runtime via /bin/sh -c). +HEALTHCHECK --interval=10s --timeout=5s --retries=5 --start-period=2s \ + CMD pg_isready --dbname="${POSTGRES_DB:-postgres}" --username="${POSTGRES_USER:-postgres}" || exit 1 + +WORKDIR /var/lib/postgresql/data diff --git a/database/postgres/conf.d/10-extensions.conf b/database/postgres/conf.d/10-extensions.conf new file mode 100644 index 0000000..ae70c13 --- /dev/null +++ b/database/postgres/conf.d/10-extensions.conf @@ -0,0 +1,4 @@ +# Preloaded extension libraries for this image. +# Applied automatically to every newly initialized cluster via +# postgresql.conf.sample (include_dir), regardless of container command. +shared_preload_libraries = 'timescaledb,vchord,pg_hint_plan,pg_stat_statements' diff --git a/database/postgres/conf.d/20-hba.conf b/database/postgres/conf.d/20-hba.conf new file mode 100644 index 0000000..38ce2ad --- /dev/null +++ b/database/postgres/conf.d/20-hba.conf @@ -0,0 +1,3 @@ +# Use the image's authoritative pg_hba.conf (which includes hba.user.d and a +# default-deny) instead of the initdb-generated one in PGDATA. +hba_file = '/usr/local/etc/postgresql/pg_hba.conf' diff --git a/database/postgres/pg_hba.conf b/database/postgres/pg_hba.conf new file mode 100644 index 0000000..e620261 --- /dev/null +++ b/database/postgres/pg_hba.conf @@ -0,0 +1,24 @@ +# Authoritative pg_hba.conf for this image. +# +# Activated via the hba_file GUC (see conf.d/20-hba.conf), so it fully replaces +# the initdb-generated $PGDATA/pg_hba.conf. This also means the docker-entrypoint's +# appended "host all all all ${POSTGRES_HOST_AUTH_METHOD}" line (written to the +# PGDATA copy) is never consulted -- ordering here is authoritative. +# +# pg_hba.conf is FIRST-MATCH-WINS, so the tiers are ordered deliberately: +# 1. local/loopback allows (first: nothing can lock out local admin) +# 2. site rules (include_dir /etc/postgresql/hba.d: mount files here) +# 3. default-deny (last: fail closed) +# +# TYPE DATABASE USER ADDRESS METHOD + +# 1. Local socket + loopback +# local all all trust +host all all 127.0.0.1/32 scram-sha-256 +host all all ::1/128 scram-sha-256 + +# 2. Site-specific rules (user-provided; mount *.conf files into this dir) +include_dir "/etc/postgresql/hba.d" + +# 3. Default-deny catch-all +host all all all reject