This commit is contained in:
Julian Sommer
2026-08-11 16:32:12 +10:00
parent 9bd14e00c9
commit 0cd749fdbf
8 changed files with 163 additions and 30 deletions
+44 -18
View File
@@ -252,14 +252,29 @@ require_data_volume() {
log "warning: ${DATA_MOUNT} is not a mount point; data will not survive a VM replace (RDS_ALLOW_LOCAL_DATADIR=1)"
}
# True when the datadir holds anything at all. A failed enumeration is neither
# "empty" nor "has content": it is a refusal, because both callers below decide
# whether something may be created or deleted from the answer.
datadir_has_content() {
[ -d "${DATADIR}" ] || return 1
_contents=$(ls -A -- "${DATADIR}" 2>/dev/null) ||
fail "could not inspect datadir ${DATADIR}; refusing to decide whether it is ours"
[ -n "${_contents}" ]
# True when the data volume holds anything that is not ours. The mount is what
# is inspected rather than ${DATADIR}: another engine lays its data out under its
# own name, so a PostgreSQL volume has content here while ${DATADIR} does not
# exist at all.
data_volume_has_content() {
[ -d "${DATA_MOUNT}" ] || return 1
# A mount that cannot be read is neither empty nor not: it is a refusal,
# because the caller decides from the answer whether to initialise.
ls -A -- "${DATA_MOUNT}" >/dev/null 2>&1 ||
fail "could not inspect ${DATA_MOUNT}; refusing to decide whether the data volume is ours"
# Globbed rather than split out of `ls`, so an entry carrying whitespace
# counts once. lost+found is the filesystem's, and the metadata and include
# directories are ours — none of the three says whose data the volume holds.
for _entry in "${DATA_MOUNT}"/* "${DATA_MOUNT}"/.[!.]* "${DATA_MOUNT}"/..?*; do
[ -e "${_entry}" ] || [ -L "${_entry}" ] || continue
case "${_entry##*/}" in
lost+found | .spinifex-rds | "${CONF_DIR##*/}") continue ;;
esac
return 0
done
return 1
}
# Written before the datadir is inspected, so a volume this image goes on to
@@ -304,11 +319,11 @@ require_engine_stamp() {
fi
# No MariaDB volume in the field predates the stamp, so unlike PostgreSQL
# there is nothing to backfill onto: an unstamped datadir with content in it
# there is nothing to backfill onto: an unstamped volume with content on it
# is by definition not ours, and stamping it would turn the check into a
# rubber stamp on exactly the volume it exists to refuse.
if datadir_has_content; then
fail "${DATADIR} holds data but the volume carries no ${ENGINE} engine stamp; refusing to touch it. Recover the volume out of band."
if data_volume_has_content; then
fail "${DATA_MOUNT} holds data but the volume carries no ${ENGINE} engine stamp; refusing to touch it. Recover the volume out of band."
fi
log "data volume carries no engine stamp — recording ${ENGINE}"
@@ -639,13 +654,24 @@ FLUSH PRIVILEGES;
SQL
}
# The master user is administrative but deliberately holds none of the
# privileges that reach outside the database: FILE is SELECT INTO OUTFILE and
# LOAD_FILE against any path as the mysql OS user, INSERT on mysql.* is native
# code execution through CREATE FUNCTION ... SONAME, SUPER and its 11.8 split-out
# successors reach SET GLOBAL and plugin loading, and SHUTDOWN stops the server
# underneath the control plane's lifecycle. Master credentials must be a database
# privilege, not a shell in the DB VM.
# The master user is administrative but is not granted the privileges that reach
# outside the database: FILE is SELECT INTO OUTFILE and LOAD_FILE against any
# path as the mysql OS user, SUPER and its 11.8 split-out successors reach SET
# GLOBAL and plugin loading, and SHUTDOWN stops the server underneath the control
# plane's lifecycle. Master credentials must be a database privilege, not a shell
# in the DB VM.
#
# What the grant list cannot do is withhold the mysql schema. MariaDB privileges
# are additive and it has no DENY, so the DML below reaches mysql.global_priv,
# and REVOKE ... ON mysql.* is refused outright (ER_NONEXISTING_GRANT) because no
# database-level row exists to revoke from. A master that writes that table and
# runs FLUSH PRIVILEGES therefore holds ALL PRIVILEGES, including the three above.
#
# What still holds after that is the boundary this preset actually depends on:
# secure_file_priv and plugin_dir are read-only variables, so 90-rds-init.cnf
# pins them past any SET GLOBAL and FILE reaches only an empty root-owned
# directory. The residual exposure is SHUTDOWN and unrecorded SET GLOBAL, not a
# shell in the DB VM.
apply_master_user() {
log "creating the master user ${RDS_MASTER_USERNAME}"
# Enumerated rather than GRANT ALL followed by revocations: a MariaDB release
+67 -6
View File
@@ -387,15 +387,35 @@ if run_ok "initialize"; then
|| fail "initialize: the database was created under the compiled default"
# Withheld deliberately: FILE is arbitrary host file access as the mysql user,
# SUPER reaches SET GLOBAL and plugin loading, and SHUTDOWN stops the server
# underneath the control plane's lifecycle.
for privilege in SUPER FILE SHUTDOWN 'ALL PRIVILEGES'; do
# SUPER and its 11.8 split-out successors reach SET GLOBAL and plugin loading,
# and SHUTDOWN stops the server underneath the control plane's lifecycle.
for privilege in SUPER FILE SHUTDOWN 'ALL PRIVILEGES' 'SET USER' \
'BINLOG ADMIN' 'BINLOG REPLAY' 'CONNECTION ADMIN' 'FEDERATED ADMIN' \
'READ_ONLY ADMIN' 'REPLICATION MASTER ADMIN' 'REPLICATION SLAVE ADMIN' \
'SLAVE MONITOR'; do
grep -q "${privilege}" "${CLIENT_CALLS}" \
&& fail "initialize: the master grant includes ${privilege}" \
|| pass "initialize: master holds no ${privilege}"
done
grep -q 'GRANT SELECT, INSERT, UPDATE, DELETE' "${CLIENT_CALLS}" \
&& pass "initialize: the grant is enumerated" || fail "initialize: no enumerated grant"
# Asserted whole rather than by prefix. A privilege appended to the list is a
# privilege the master silently gains, and the enumeration exists precisely so
# that widening it has to be deliberate.
_grant=$(sed -n '/^GRANT /,/WITH GRANT OPTION;/p' "${CLIENT_CALLS}" | tr '\n' ' ' | tr -s ' ')
_expected_grant="GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, RELOAD, PROCESS, \
REFERENCES, INDEX, ALTER, SHOW DATABASES, CREATE TEMPORARY TABLES, \
LOCK TABLES, EXECUTE, REPLICATION SLAVE, REPLICATION CLIENT, \
CREATE VIEW, SHOW VIEW, CREATE ROUTINE, ALTER ROUTINE, \
CREATE USER, EVENT, TRIGGER ON *.* TO 'mulgamaster'@'%' WITH GRANT OPTION; "
[ "${_grant}" = "${_expected_grant}" ] \
&& pass "initialize: the grant is exactly the enumerated list" \
|| fail "initialize: the master grant has changed: ${_grant}"
# MariaDB privileges are additive and it has no DENY, so scope is the only
# control left: a second ON clause would widen the master beyond this list.
[ "$(grep -c '^ ON ' "${CLIENT_CALLS}")" = "1" ] \
&& pass "initialize: the grant is scoped ON *.* and nothing else" \
|| fail "initialize: the master carries more than one grant scope"
grep -q "WITH GRANT OPTION" "${CLIENT_CALLS}" \
&& pass "initialize: master can delegate its own privileges" \
|| fail "initialize: master cannot grant"
@@ -699,7 +719,7 @@ unset LS_FAIL
[ -s "${INSTALLDB_CALLS}" ] \
&& fail "datadir-probe-fail: initialised after the probe failed" \
|| pass "datadir-probe-fail: nothing created"
grep -q 'could not inspect datadir' "${WORK}/out" \
grep -q 'refusing to decide whether the data volume is ours' "${WORK}/out" \
&& pass "datadir-probe-fail: refusal explains why" || fail "datadir-probe-fail: no refusal message"
# --- Case 6c: the bootstrap server never starts ---
@@ -1012,6 +1032,47 @@ if run_ok "stamp-absent-empty"; then
grep -q 'no mariadb engine stamp' "${WORK}/out" \
&& pass "stamp-absent-nonempty: refusal names the missing stamp" \
|| fail "stamp-absent-nonempty: no refusal message"
# --- Case 12f: an unstamped PostgreSQL volume is refused ---
# The one unstamped volume that actually exists in the field, and the reason
# emptiness is decided on the mount: PostgreSQL keeps its cluster under
# ${DATA_MOUNT}/18/data, so ${DATADIR} is absent and the volume reads as new.
rm -rf "${DATADIR}" "${STAMP}"
mkdir -p "${DATA_MOUNT}/18/data"
echo 'customer cluster data' > "${DATA_MOUNT}/18/data/PG_VERSION"
: > "${INSTALLDB_CALLS}"
run_fails "stamp-absent-pgvolume"
[ -s "${INSTALLDB_CALLS}" ] \
&& fail "stamp-absent-pgvolume: initialised over a PostgreSQL volume" \
|| pass "stamp-absent-pgvolume: nothing created"
[ -f "${DATA_MOUNT}/18/data/PG_VERSION" ] \
&& pass "stamp-absent-pgvolume: the PostgreSQL cluster is preserved" \
|| fail "stamp-absent-pgvolume: the PostgreSQL cluster was touched"
# A stamp here is the worst outcome of all: the PostgreSQL image refuses a
# 'mariadb' stamp too, so the volume becomes unreachable from both engines.
[ -e "${STAMP}" ] \
&& fail "stamp-absent-pgvolume: another engine's volume was stamped ours" \
|| pass "stamp-absent-pgvolume: no stamp written"
grep -q 'no mariadb engine stamp' "${WORK}/out" \
&& pass "stamp-absent-pgvolume: refusal names the missing stamp" \
|| fail "stamp-absent-pgvolume: no refusal message"
rm -rf "${DATA_MOUNT}/18"
# --- Case 12g: lost+found alone does not make a fresh volume look used ---
# ext4 creates it at mkfs, so counting it as content would refuse every
# genuinely new data volume.
rm -rf "${DATADIR}" "${STAMP}"
mkdir -p "${DATA_MOUNT}/lost+found"
: > "${INSTALLDB_CALLS}"
write_handoff initialize 's3cr3t' appdb
if run_ok "stamp-absent-lostfound"; then
[ -s "${INSTALLDB_CALLS}" ] \
&& pass "stamp-absent-lostfound: a fresh volume still initialises" \
|| fail "stamp-absent-lostfound: nothing was initialised"
[ "$(cat "${STAMP}")" = "mariadb" ] \
&& pass "stamp-absent-lostfound: the fresh volume was stamped" \
|| fail "stamp-absent-lostfound: no stamp written"
fi
fi
# --- Case 12e: a stamp that cannot be written is fatal, before initialisation ---