✨ 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.
This commit is contained in:
akritikos committed 2026-08-15 19:57:25 +03:00
commit 5744f4b7db
20 files changed
+1023

No files matched your search

+10
View File
@@ -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
+50
View File
@@ -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}
+16
View File
@@ -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"]
@@ -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 (<db>_readers | <db>_writers | <db>_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 (<db>_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."
+117
View File
@@ -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:
# <db>_readers | <db>_writers | <db>_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."
+61
View File
@@ -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
+203
View File
@@ -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 <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."
+56
View File
@@ -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"]
+11
View File
@@ -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
+57
View File
@@ -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" <<EOF
uri ${LDAPURI}
base ${LDAP_BASE}
binddn ${LDAPBINDDN}
bindpw ${LDAPPASSWORD}
scope sub
referrals no
pam_login_attribute ${LDAP_LOGIN_ATTR}
pam_filter objectclass=User
tls_cacertdir /etc/ssl/certs
tls_reqcert ${LDAP_TLS_REQCERT}
EOF
chmod 600 "$conf"
done
# 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)"
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 "$@"
@@ -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 <<SQL
-- Monitor account: mariadbmon health checks, replication state, and failover.
CREATE USER IF NOT EXISTS 'maxscale_monitor'@'%' IDENTIFIED BY '${MAXSCALE_MONITOR_PASSWORD}';
GRANT BINLOG MONITOR, SLAVE MONITOR, REPLICATION SLAVE, REPLICATION MASTER ADMIN,
REPLICATION SLAVE ADMIN, READ_ONLY ADMIN, RELOAD, PROCESS,
SHOW DATABASES, EVENT
ON *.* TO 'maxscale_monitor'@'%';
-- Service account: reads the user tables so MaxScale can authenticate clients
-- and route them (pass-through auth defers the real check to the backend PAM).
CREATE USER IF NOT EXISTS 'maxscale_service'@'%' IDENTIFIED BY '${MAXSCALE_SERVICE_PASSWORD}';
GRANT SELECT ON mysql.* TO 'maxscale_service'@'%';
GRANT SHOW DATABASES ON *.* TO 'maxscale_service'@'%';
-- Replication account: the replica's IO thread uses this to stream the binlog.
CREATE USER IF NOT EXISTS 'repl'@'%' IDENTIFIED BY '${MARIADB_REPL_PASSWORD}';
GRANT REPLICATION SLAVE ON *.* TO 'repl'@'%';
SQL
+6
View File
@@ -0,0 +1,6 @@
# 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
+79
View File
@@ -0,0 +1,79 @@
ARG ALPINE=latest
ARG PG_MAJOR=18
ARG PGPOOL_VERSION=4.7.2
FROM alpine:${ALPINE} AS base
FROM base AS builder
ARG PG_MAJOR
ARG PGPOOL_VERSION
RUN apk add --no-cache \
build-base \
linux-headers \
postgresql${PG_MAJOR}-dev \
openssl-dev \
openldap-dev \
libmemcached-dev \
wget
WORKDIR /tmp
RUN wget -qO- https://www.pgpool.net/source/pgpool-II-${PGPOOL_VERSION}.tar.gz | tar xz
WORKDIR /tmp/pgpool-II-${PGPOOL_VERSION}
RUN ./configure \
--prefix=/usr \
--sysconfdir=/etc/pgpool-II \
--with-openssl \
--with-ldap \
--with-memcached=/usr \
&& make -j"$(nproc)" \
&& make install DESTDIR=/build
WORKDIR /tmp/pgpool-II-${PGPOOL_VERSION}/src/sql
RUN make -j"$(nproc)" && make install DESTDIR=/build
# ── Runtime stage ──────────────────────────────────────────────────────────
FROM base
ARG PG_MAJOR
RUN apk add --no-cache \
bash \
postgresql${PG_MAJOR}-client \
libldap \
openssl \
libmemcached-libs \
ca-certificates \
&& update-ca-certificates
# Copy built binaries, libs and default configs
COPY --from=builder /build/usr/ /usr/
COPY --from=builder /build/etc/ /etc/
# Runtime directories
RUN mkdir -p /var/run/pgpool /var/log/pgpool /etc/pgpool-II \
&& adduser -D -h /var/run/pgpool pgpool \
&& chown pgpool:pgpool /var/run/pgpool /var/log/pgpool
# Ship a minimal default config (overridden by bind-mount at runtime)
RUN set -eu; \
cp /etc/pgpool-II/pgpool.conf.sample /etc/pgpool-II/pgpool.conf; \
cp /etc/pgpool-II/pool_hba.conf.sample /etc/pgpool-II/pool_hba.conf; \
cp /etc/pgpool-II/pcp.conf.sample /etc/pgpool-II/pcp.conf
EXPOSE 9999 9898
# Green means pgpool can hand a client a backend connection, i.e. at least one
# node is attached AND a primary is identifiable -- NOT that the cluster is
# intact: a degenerated standby still reports healthy. Retries exist to ride out
# the ~1 health_check_period window where failover makes connections fail.
HEALTHCHECK --interval=10s --timeout=5s --retries=5 --start-period=10s \
CMD pg_isready -h 127.0.0.1 -p 9999 || exit 1
# AES passwords in pool_passwd: mount the key anywhere and set PGPOOLKEYFILE to
# it; the entrypoint re-stages it at the permissions pgpool insists on.
COPY --chmod=0755 docker-entrypoint.sh /usr/local/bin/
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["pgpool", "-n", "-f", "/etc/pgpool-II/pgpool.conf", "-F", "/etc/pgpool-II/pcp.conf"]
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
# ── Clean up stale PID files from previous runs ───────────────────────────
rm -f /tmp/pgpool.pid /var/run/pgpool/pgpool.pid
exec "$@"
+115
View File
@@ -0,0 +1,115 @@
ARG PG_MAJOR=18
ARG POSTGIS_MAJOR=3
ARG VCHORD_VERSION=1.1.1
FROM postgres:${PG_MAJOR:-18} AS base-image
WORKDIR /tmp
FROM base-image AS build-deps
ARG PG_MAJOR
RUN apt-get update && apt-get install -y --no-install-recommends \
wget \
curl \
build-essential \
cmake \
libkrb5-dev \
libicu-dev \
postgresql-server-dev-$PG_MAJOR \
&& apt-get install --reinstall -y ca-certificates \
&& update-ca-certificates
FROM build-deps AS downloads
WORKDIR /tmp
ARG PG_MAJOR
ARG VCHORD_VERSION
RUN wget https://github.com/tensorchord/VectorChord/releases/download/${VCHORD_VERSION}/postgresql-${PG_MAJOR}-vchord_${VCHORD_VERSION}-1_amd64.deb
FROM downloads AS timescaledb
ARG PG_MAJOR
WORKDIR /tmp/timescaledb
RUN URL_END=$(case "$PG_MAJOR" in ("12") echo "tag/2.11.2";; ("13") echo "tag/2.15.3";; (*) echo "latest";; esac) && \
ASSET_NAME=$(basename $(curl -LIs -o /dev/null -w %{url_effective} https://github.com/timescale/timescaledb/releases/${URL_END})) && \
curl --fail -L "https://github.com/timescale/timescaledb/archive/${ASSET_NAME}.tar.gz" | tar -zx --strip-components=1 -C . && ./bootstrap
WORKDIR /tmp/timescaledb/build
RUN make -j$(nproc) && \
make install
FROM base-image AS final
WORKDIR /tmp
ARG PG_MAJOR
ARG POSTGIS_MAJOR
ARG VCHORD_VERSION
## Install postgis, pg_hint_plan and pgvector
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
libldap-common \
postgresql-${PG_MAJOR}-postgis-${POSTGIS_MAJOR} \
postgresql-${PG_MAJOR}-postgis-${POSTGIS_MAJOR}-scripts \
postgis \
postgresql-${PG_MAJOR}-pgrouting \
postgresql-${PG_MAJOR}-pg-hint-plan \
postgresql-${PG_MAJOR}-pgvector \
&& apt-get install --reinstall -y ca-certificates
## Copy TimescaleDB from build image
COPY --from=timescaledb \
/usr/share/postgresql/$PG_MAJOR/extension/timescaledb* \
/usr/share/postgresql/$PG_MAJOR/extension/
COPY --from=timescaledb \
/usr/lib/postgresql/$PG_MAJOR/lib/timescaledb* \
/usr/lib/postgresql/$PG_MAJOR/lib/
## Install VectorChord
COPY --from=downloads /tmp/postgresql-${PG_MAJOR}-vchord_${VCHORD_VERSION}-1_amd64.deb /tmp/
RUN apt-get install -y --no-install-recommends ./postgresql-${PG_MAJOR}-vchord_${VCHORD_VERSION}-1_amd64.deb
## Mark curated extensions as trusted so non-superusers with CREATE on their
## database (e.g. app migration roles) can install them without superuser.
## These ship untrusted, otherwise yielding "permission denied to create
## extension". Safe because each only adds data types / index access methods /
## planner hints -- no filesystem, network, raw-storage, or SECURITY DEFINER
## access. Do NOT add postgis*, *_fdw, dblink, or storage-introspection modules.
RUN set -eu; \
for ext in vector vchord pg_hint_plan; do \
ctl="/usr/share/postgresql/${PG_MAJOR}/extension/${ext}.control"; \
[ -f "$ctl" ] || { echo "trusted-marking: missing $ctl" >&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
## <method>" 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
@@ -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'
+3
View File
@@ -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'
+24
View File
@@ -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