From 5c8a7a30bedd035fab362d71001ed6a3d8a3e852 Mon Sep 17 00:00:00 2001 From: Johannes Rest Date: Thu, 10 Sep 2026 14:52:04 +0200 Subject: [PATCH] Check database dump privileges before stopping applications --- Dokumentation.md | 22 ++++++++++- README.md | 8 +++- UPDATE_ANLEITUNG.md | 87 +++++++++++++++++++++++++++++++++++++++++++- lib/backup_set.py | 52 ++++++++++++++++++++++++++ lib/common.sh | 17 ++++++++- tests/test_backup.py | 62 +++++++++++++++++++++++++++++++ 6 files changed, 241 insertions(+), 7 deletions(-) diff --git a/Dokumentation.md b/Dokumentation.md index 8836e2f..83e4968 100644 --- a/Dokumentation.md +++ b/Dokumentation.md @@ -138,7 +138,23 @@ Es wird je aktivierter Anwendung ein Dump benötigt, sofern `ENABLE_DB_DUMPS=tru Für gemischte Storage Engines nutzt das Paket `--lock-all-tables --quick`. **Die globale Lesesperre kann während jedes Dumps Schreibzugriffe auf alle Datenbanken desselben Servers blockieren.** Eine reine `--single-transaction`-Sicherung schützt insbesondere nicht alle nichttransaktionalen Tabellen. Zusätzlich müssen Anwendungsschreiber über das gesamte Ruhefenster angehalten sein, damit Dateikopien zu den Dumps passen. Quelle: [MariaDB mariadb-dump](https://mariadb.com/docs/server/clients-and-utilities/backup-restore-and-import-clients/mariadb-dump). -Die konkreten Rechte hängen von Serverversion, Views, Routinen und Sperrmethode ab. Der Backup-Account benötigt Leserechte sowie Rechte für Views, Trigger, Events, Routinen und die globale Sperre; ein früherer einfacher `SELECT`-Account reicht möglicherweise nicht. Die Rechte gezielt auf eure Version zuschneiden. `--check` prüft Anmeldung und DB-Zugriff, **nicht sämtliche später benötigten Dump-Privilegien**. Die Integrationstests verwenden einen isolierten administrativen Testaccount. +Die konkreten Rechte hängen von Serverversion, Views, Routinen und Sperrmethode ab. +Der Backup-Account benötigt Leserechte sowie Rechte für Views, Trigger, Events, +Routinen und die globale Sperre. Für `--lock-all-tables` ist insbesondere globales +`RELOAD` erforderlich; Rechte nur auf `wordpress.*` reichen dafür nicht. +`--check` prüft Anmeldung, DB-Zugriff und dieses globale Recht über lesende +Privileg-Metadaten. Bei MariaDB wird auch `SELECT * FROM mysql.proc LIMIT 0` +geprüft: `--routines` benötigt diesen Lesezugriff. Die Abfrage liefert keine +Routineninhalte. Die Prüfung führt kein `FLUSH` aus und nimmt keine globale DB-Lesesperre. +Authentifizierungsdaten aus `SHOW GRANTS` werden weder ausgegeben noch gespeichert. +Direkte Grants und bei MariaDB aktivierte, auch verschachtelte Rollen werden +berücksichtigt; lediglich zugewiesene, aber nicht aktivierte Rollen reichen nicht. +Andere Rollenmodelle oder nicht auswertbare Rollennamen benötigen einen direkt +nachweisbaren Grant für das Backup-Konto. Die Prüfung belegt **nicht sämtliche +objekt- und versionsabhängigen Dump-Rechte**; der vollständige Probelauf bleibt +erforderlich. Zusätzlich zum administrativen Testkonto prüfen die Integrationstests +einen eingeschränkten Backup-Account mit gezielten Grants und MariaDB-Rollen. +Quelle: [MariaDB FLUSH / RELOAD](https://mariadb.com/docs/server/reference/sql-statements/administrative-sql-statements/flush-commands/flush). Für einen Restore einen separaten Account mit DDL-/DML-Rechten auf die Ziel-Datenbanken verwenden. Importe sind nicht als Ganzes transaktional. Eine leere vorbereitete Zieldatenbank vermeidet, dass Tabellen oder andere Objekte zurückbleiben, die im Backup nicht vorkommen. Das Skript löscht keine vollständige Datenbank und legt keine Datenbanken/Accounts automatisch an. @@ -315,10 +331,12 @@ Die Entfernung der drei DB-Dateien aus dem aktuellen Baum löscht deren Historie Reproduzierbar mit `tests/Dockerfile` und `tests/run.sh`. Der Container bekommt das Repository nur lesbar und benötigt während der Tests kein Netzwerk. Die MariaDB läuft ohne TCP-Port in einem temporären Verzeichnis. systemd/occ werden simuliert; alle eigentlichen Archive, Dateikopien, Dumps/Imports und Crypt-Operationen sind real. -Ergebnis des lokalen Abschlusslaufs am 10.09.2026: **37 Tests erfolgreich**, Shell-Syntax und ShellCheck ohne Befund. Testumgebung: Debian 13.6, MariaDB 11.8.6, rclone 1.60.1-DEV (Distributionspaket), ShellCheck 0.10.0. Diese Versionsangaben beschreiben die Testumgebung, keine Empfehlung für die Produktivversion. +Ergebnis des lokalen Abschlusslaufs am 10.09.2026: **41 Tests erfolgreich**, Shell-Syntax und ShellCheck ohne Befund. Testumgebung: Debian 13.6, MariaDB 11.8.6, rclone 1.60.1-DEV (Distributionspaket), ShellCheck 0.10.0. Diese Versionsangaben beschreiben die Testumgebung, keine Empfehlung für die Produktivversion. Geprüfte Fälle umfassen: +- Fehlendes globales `RELOAD` bei eingeschränktem Konto: Vorprüfungsabbruch ohne Installation/Writer-Stopp, anschließend erfolgreicher Paketlauf nach gezieltem Grant. Aktive verschachtelte MariaDB-Rollen werden anerkannt, inaktive Rollen abgewiesen. +- Fehlender Zugriff auf MariaDB-Routinen-Metadaten vor Wartungsmodus erkannt; nach gezielter Berechtigung tatsächlicher Dump mit gespeicherter Prozedur geprüft. - Fehlender Mailstore ohne Live-Änderung, explizite Pfadkorrektur, Homes-only-Update samt tatsächlichem Maildatei-Restore und Ablehnung unvollständiger Home-Konfigurationen. - Shell-Syntax und ShellCheck der Paketdateien. - MyISAM-Dump und tatsächlicher SQL-Import samt Dateiwiederherstellung. diff --git a/README.md b/README.md index b6c1ce3..eb1db37 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,13 @@ sudo journalctl -u app-backup.service -n 100 --no-pager sudo cat /var/backups/app-backup/last-backup.json ``` -`--check` prüft Werkzeuge, Quellpfade, Dateirechte, DB-Verbindungen, Speicherreserve und Crypt-Zugriff. Es stoppt keine Anwendungen und führt weder Backup noch Upload aus. Es ersetzt keinen vollständigen Probelauf. +`--check` prüft Werkzeuge, Quellpfade, Dateirechte, DB-Verbindungen, das globale +DB-Recht `RELOAD` für die Dump-Sperre, bei MariaDB den Zugriff auf Routinen-Metadaten, +Speicherreserve und Crypt-Zugriff. +Die Rechteprüfung liest nur Metadaten; sie stoppt keine Anwendungen, sperrt keine +Datenbank und führt weder Backup noch Upload aus. Sie ersetzt keinen vollständigen +Probelauf. Fehlt `RELOAD`, die tatsächlich verwendeten Backup-Konten ermitteln und +gezielt berechtigen; siehe [DB-Rechte korrigieren](UPDATE_ANLEITUNG.md#fehlendes-db-recht-reload). In der Konfiguration insbesondere ergänzen/prüfen: diff --git a/UPDATE_ANLEITUNG.md b/UPDATE_ANLEITUNG.md index 20e8e7e..53660fe 100644 --- a/UPDATE_ANLEITUNG.md +++ b/UPDATE_ANLEITUNG.md @@ -21,7 +21,9 @@ Backups mit zeitweiliger Unterbrechung der konfigurierten Anwendungen. 2. Bestehende Einstellungen nach `/var/backups/app-backup-upgrade-XXXXXXXX` sichern. 3. Aktuelle Pfade und Einstellungen übernehmen; sichere Betriebsoptionen ergänzen. 4. Vorhandene Webdienste und direkt erkennbare Nextcloud-systemd-Writer zuordnen. -5. Crypt-Konfiguration und DB-Dateien prüfen; vorhandene Schlüssel/Passwörter erhalten. +5. Crypt-Konfiguration, DB-Dateien, globales DB-Recht `RELOAD` und bei MariaDB + Zugriff auf Routinen-Metadaten prüfen; + vorhandene Schlüssel/Passwörter erhalten. Keine automatischen DB-Grants. 6. Vorprüfung mit geschützten Kopien durchführen. Bei Fehlern bleibt das installierte Paket samt Anwendungskonfiguration und Timer unverändert. 7. Einen von SSH unabhängigen systemd-Auftrag `app-backup-upgrade.service` mit @@ -191,6 +193,87 @@ startet den Timer nicht ungeprüft. Neu erzeugte root-eigene Kopien von rclone-D sowie strengere Dateirechte bleiben erhalten. Bei unterbrochener Vorbereitung ohne Installationsbeginn ist kein Paket-Rollback erforderlich. +### Fehlendes DB-Recht RELOAD + +`mariadb-dump: ... FLUSH ... Access denied ... RELOAD ... (1227)` bedeutet: +Die Anmeldung funktioniert, aber dem tatsächlich verwendeten DB-Konto fehlt das +globale Sperrrecht für `--lock-all-tables`. Ein pauschaler Wechsel auf einen Dump +ohne Sperren oder ausschließlich `--single-transaction` ist bei gemischten +Storage Engines kein gleichwertiger Ersatz. Das neue Paket prüft `RELOAD` bereits +vor Installation und Wartungsmodus über lesende Privileg-Metadaten. + +Die tatsächlich authentifizierten Konten und Serverversionen ermitteln. Zunächst +eine Root-Shell starten: + +```bash +sudo bash +``` + +Erst in dieser Shell den folgenden Block ausführen. Er gibt keine Passwörter +oder Authentifizierungshashes aus und beendet anschließend die Root-Shell: + +```bash +source /etc/app-backup/app-backup.conf +query='SELECT CURRENT_USER(), VERSION()' +echo WordPress +mariadb --defaults-extra-file="$WP_DB_CNF" -N -B -e "$query" +echo Nextcloud +mariadb --defaults-extra-file="$NC_DB_CNF" -N -B -e "$query" +echo Gitea +mariadb --defaults-extra-file="$GITEA_DB_CNF" -N -B -e "$query" +exit +``` + +Nur die Zeilen für tatsächlich konfigurierte Anwendungen ausführen. Die getrennte +Root-Shell vermeidet Probleme durch kopierte Einrückungen an Here-Doc-Abschlussmarken. + +Mit einem DB-Administratorkonto nur die betroffenen, vorhandenen Backup-Konten +berechtigen. Beispiel für das **zuvor tatsächlich identifizierte** Konto +`backup@localhost` (nicht ungeprüft übernehmen): + +```sql +GRANT RELOAD ON *.* TO 'backup'@'localhost'; +``` + +`RELOAD` ist ein globales Verwaltungsrecht zum Flush/Reload; deshalb wird es nicht +automatisch durch den Paketinstaller vergeben. Keine pauschalen `ALL PRIVILEGES` +oder `GRANT OPTION` erteilen. Wenn die CNF ein produktives Anwendungskonto verwendet, +ein separates Backup-Konto mit gezielten Rechten vorbereiten. Ein zusätzliches +`FLUSH PRIVILEGES` ist nach `GRANT` nicht notwendig. Quelle: +[MariaDB GRANT](https://mariadb.com/docs/server/reference/sql-statements/account-management-sql-statements/grant). + +Für MariaDB prüft das Paket außerdem den Lesezugriff auf `mysql.proc`, den die +verwendete Option `--routines` benötigt. Falls dieser fehlt, gezielt für das +identifizierte Backup-Konto vergeben: + +```sql +GRANT SELECT ON mysql.proc TO 'backup'@'localhost'; +``` + +Auch dies ist ein Beispiel mit zu ersetzendem Kontonamen. Es erlaubt den Zugriff +auf Routinen-Metadaten datenbankübergreifend; kein `SELECT ON mysql.*` vergeben. +Quelle: [MariaDB mariadb-dump / routines](https://mariadb.com/docs/server/clients-and-utilities/backup-restore-and-import-clients/mariadb-dump). + +Weitere erforderliche Rechte hängen von Views, Triggern und Events ab; +`RELOAD` allein ersetzt keine Leserechte auf den zu sichernden Datenbanken. +Die neue Prüfung deckt die globale Sperrberechtigung und MariaDB-Routinen-Metadaten +ab, nicht sämtliche möglichen Objektrechte. Der vollständige Abnahmelauf bleibt verpflichtend. + +Beim gemeldeten Lauf vom 10.09.2026 um 14:41 Uhr war das Paket bereits installiert, +als der erste Dump scheiterte. Nextcloud verließ laut Log den Wartungsmodus und +Cron wurde wieder gestartet. Das ist **kein erfolgreiches Backup**; der Timer +bleibt bis zur Abnahme gestoppt. Nach Rechtekorrektur das aktuelle Paket erneut +starten: Es prüft, sichert und aktiviert den Timer erst nach erfolgreicher +Remote-Prüfung. Ein erneuter Paketlauf erhält die ursprüngliche Sicherung des +vorigen Update-Versuchs. Für einen Rückweg zum Stand vor dem ersten Installationslauf +ist dessen Rollback-Ordner maßgeblich; ein späterer Snapshot enthält bereits die +zuvor installierte Version. + +Dienst-Stopps/-Starts und das Warten auf Nextcloud-Writer werden jetzt ausdrücklich +im Journal protokolliert. Eine längere Pause ohne CPU-Last lässt sich so einem +konkreten Dienst zuordnen; ohne dessen Journal lässt sich die Ursache der Pause +nicht aus ihrer Dauer allein bestimmen. + ## Paket reproduzieren Aus einem sauberen, eingecheckten Repository: @@ -207,7 +290,7 @@ Das ausführbare Paket ausschließlich über euren vertrauenswürdigen Kanal bez ## Verifikation der Lieferung -37 Container-Tests sowie ShellCheck und Shell-Syntaxprüfung erfolgreich. +41 Container-Tests sowie ShellCheck und Shell-Syntaxprüfung erfolgreich. Die Tests enthalten echte MariaDB-Dumps/Imports und lokale rclone-Crypt-Roundtrips; systemd/occ und die systemd-run-Übergabe sind simuliert. Das Übergabemodell berücksichtigt jetzt das Warten auf den oneshot-Startauftrag: Der Regressionstest diff --git a/lib/backup_set.py b/lib/backup_set.py index dc439a4..8943b74 100644 --- a/lib/backup_set.py +++ b/lib/backup_set.py @@ -170,6 +170,56 @@ def crypt(name): print("Crypt configuration accepted (contents, filenames, directories)") +def has_reload(grants): + """Inspect privilege names only; SHOW GRANTS can contain authentication hashes.""" + for line in grants.splitlines(): + match = re.match(r'^GRANT ([A-Z_ ,]+) ON \*\.\* TO ', line) + if match and {'RELOAD', 'ALL PRIVILEGES'} & set(match[1].split(', ')): + return True + return False + + +def db_privileges(client, credentials, database): + """Read privileges without executing FLUSH or acquiring a production DB lock. + + Check lock-all-tables and MariaDB's routine metadata prerequisite, not every + object-specific permission. Grants (including auth hashes) stay in memory. + """ + def query(sql, purpose='privilege metadata'): + result = subprocess.run([client, '--defaults-extra-file=' + credentials, + '--database=' + database, '--batch', '--skip-column-names', + '--execute=' + sql], capture_output=True, text=True) + if result.returncode: + # Neither query results nor server errors may disclose auth material. + raise ValueError(f'DB {database}: {purpose} query failed; inspect account permissions') + return result.stdout + + grants = query('SHOW GRANTS FOR CURRENT_USER') + allowed = has_reload(grants) + via_role = False + mariadb = 'MariaDB' in query('SELECT VERSION()') + if not allowed and mariadb: + # Only session-enabled roles count; a merely granted but inactive role + # does not help mariadb-dump. ENABLED_ROLES also includes nested roles. + roles = query('SELECT ROLE_NAME FROM information_schema.ENABLED_ROLES WHERE ROLE_NAME IS NOT NULL').splitlines() + for role in roles: + if not re.fullmatch(r'[A-Za-z0-9_.:-]+', role): + raise ValueError(f'DB {database}: unsupported role name; configure direct RELOAD grant for backup account') + if has_reload(query('SHOW GRANTS FOR `' + role + '`')): + allowed = via_role = True + break + if not allowed: + raise ValueError(f'DB {database}: global RELOAD privilege missing or not verifiable; ' + 'required for --lock-all-tables. Grant RELOAD to the actual backup account; ' + 'see UPDATE_ANLEITUNG.md. No FLUSH or database lock performed by this check') + print(f'DB {database}: global RELOAD privilege confirmed' + (' through an enabled role' if via_role else '')) + if mariadb: + # --routines needs mysql.proc access. LIMIT 0 checks permissions without + # returning routine bodies or reading the stored routine contents. + query('SELECT * FROM mysql.proc LIMIT 0', 'routine metadata (SELECT on mysql.proc required for --routines)') + print(f'DB {database}: MariaDB routine metadata access confirmed') + + def candidates(prefix, days, keep): # Only v2 names supplied AFTER completion-marker validation are eligible. names = set(sys.stdin.read().splitlines()) @@ -233,6 +283,8 @@ def main(): op, *args = sys.argv[1:] if op == "wait-nextcloud": wait_nextcloud(*args) + elif op == "db-privileges": + db_privileges(*args) elif op == "status": write_status(*args) elif op == "crypt": diff --git a/lib/common.sh b/lib/common.sh index 0b07c11..6089ef9 100644 --- a/lib/common.sh +++ b/lib/common.sh @@ -197,6 +197,11 @@ preflight() { need "$DB_DUMP_BIN"; need "$DB_CLIENT_BIN" for i in "${!DB_LABELS[@]}"; do "$DB_CLIENT_BIN" --defaults-extra-file="${DB_CNFS[i]}" --database="${DB_NAMES[i]}" --execute='SELECT 1' >/dev/null + # Restore uses different privileges; this prerequisite belongs to dumps. + # Metadata only: never briefly lock the live database during --check. + if [[ $OPERATION == backup ]]; then + helper db-privileges "$DB_CLIENT_BIN" "${DB_CNFS[i]}" "${DB_NAMES[i]}" + fi done fi if [[ $ENABLE_NEXTCLOUD == true ]]; then need sudo; need php; [[ -f $NC_DIR/occ ]] || die "Nextcloud occ missing"; fi @@ -262,7 +267,9 @@ stop_service() { # Record intent before stop: a failed/partial stop still needs recovery. STOPPED_SERVICES+=("$1") save_recovery + echo "-- Stopping service: $1 (waiting for systemd)" systemctl stop "$1" + echo "-- Service stopped: $1" ;; inactive|failed) ;; *) die "Unknown/transitioning service state for $1: $state" ;; @@ -278,7 +285,7 @@ quiesce() { if [[ $NC_WAS_ON == false ]]; then NC_CHANGED=true; save_recovery; occ maintenance:mode --on; fi else echo "WARNING: Nextcloud maintenance disabled; consistency depends on external writer shutdown"; fi fi - # Include application cron/worker services in this array; crond itself is not stopped. + # Cron daemons are included only if explicitly configured by the operator. for p in "${QUIESCE_SERVICES[@]}"; do stop_service "$p"; done if [[ $ENABLE_WORDPRESS == true || $ENABLE_NEXTCLOUD == true ]]; then if [[ $ENABLE_HTTPD_STOP == true ]]; then stop_service "$HTTPD_SERVICE_NAME"; fi @@ -294,7 +301,9 @@ quiesce() { if [[ $ENABLE_NEXTCLOUD == true ]]; then # Cron daemons may leave an already launched PHP child running after stop. # Wait for direct cron.php / occ processes to finish; never kill them blindly. + echo '-- Waiting for remaining Nextcloud writers' helper wait-nextcloud "$NC_DIR" "${WRITER_DRAIN_TIMEOUT:=300}" + echo '-- Nextcloud writers finished' fi } # Survives SIGKILL/power loss, which Bash traps cannot handle. This is an @@ -317,7 +326,11 @@ resume_services() { fi for ((i=${#STOPPED_SERVICES[@]}-1; i>=0; i--)); do [[ -n ${STOPPED_SERVICES[i]} ]] || continue - if systemctl start "${STOPPED_SERVICES[i]}"; then STOPPED_SERVICES[i]=''; else failed=1; fi + echo "-- Starting service: ${STOPPED_SERVICES[i]} (waiting for systemd)" + if systemctl start "${STOPPED_SERVICES[i]}"; then + echo "-- Service started: ${STOPPED_SERVICES[i]}" + STOPPED_SERVICES[i]='' + else failed=1; fi done if (( failed == 0 )); then rm -f -- "$WORKDIR/recovery-$RUN_ID.json" diff --git a/tests/test_backup.py b/tests/test_backup.py index 4d287be..83338eb 100644 --- a/tests/test_backup.py +++ b/tests/test_backup.py @@ -22,6 +22,12 @@ SPEC.loader.exec_module(HELPER) class ArchiveValidation(unittest.TestCase): + def test_reload_parser_requires_global_privilege(self): + self.assertTrue(HELPER.has_reload("GRANT SELECT, RELOAD ON *.* TO `backup`@`localhost` IDENTIFIED BY PASSWORD 'secret-hash'")) + self.assertTrue(HELPER.has_reload('GRANT ALL PRIVILEGES ON *.* TO `root`@`localhost`')) + self.assertFalse(HELPER.has_reload('GRANT ALL PRIVILEGES ON `wordpress`.* TO `backup`@`localhost`')) + self.assertFalse(HELPER.has_reload('GRANT `RELOAD` TO `backup`@`localhost`')) + def archive(self, entries): temp = tempfile.TemporaryDirectory() self.addCleanup(temp.cleanup) @@ -440,6 +446,62 @@ QUIESCE_SERVICES=(nc-cron) self.assertEqual(json.loads(self.state.read_text())['services']['app-backup.timer'],'active') self.assertNotIn('systemctl stop',self.events.read_text()) + def restricted_backup_account(self): + self.sql("CREATE USER 'app_backup_limited'@'localhost'; " + "GRANT SELECT, SHOW VIEW, EVENT, TRIGGER ON backup_test.* TO 'app_backup_limited'@'localhost'; " + "GRANT SELECT ON mysql.proc TO 'app_backup_limited'@'localhost';") + self.addCleanup(self.sql, "DROP USER 'app_backup_limited'@'localhost'") + self.cnf.write_text(f'[client]\nuser=app_backup_limited\nsocket={self.socket}\n') + + def test_missing_reload_blocks_upgrade_then_targeted_grant_allows_backup(self): + canonical=self.configure_upgrade() + self.restricted_backup_account() + original=canonical.read_bytes() + package=self.build_package() + result=subprocess.run(['bash',str(package),'--foreground'],env=self.env,capture_output=True,text=True,timeout=60) + self.assertNotEqual(result.returncode,0,result.stdout+result.stderr) + self.assertIn('global RELOAD privilege missing',result.stdout+result.stderr) + self.assertEqual(canonical.read_bytes(),original) + self.assertEqual(self.cnf.stat().st_mode & 0o777,0o640) + self.assertNotIn('systemctl stop',self.events.read_text()) + self.assertEqual(json.loads(self.state.read_text())['services']['app-backup.timer'],'active') + self.sql("GRANT RELOAD ON *.* TO 'app_backup_limited'@'localhost'") + result=subprocess.run(['bash',str(package),'--foreground'],env=self.env,capture_output=True,text=True,timeout=120) + self.assertEqual(result.returncode,0,result.stdout+result.stderr) + state=Path(result.stdout.split('Update-Sicherung und Protokoll: ')[1].splitlines()[0]) + self.assertTrue((state/'result.txt').read_text().startswith('SUCCESS')) + + def test_reload_from_enabled_nested_role_is_accepted_but_inactive_role_is_not(self): + self.restricted_backup_account() + self.sql("CREATE ROLE backup_reload_child; CREATE ROLE backup_reload_parent; " + "GRANT RELOAD ON *.* TO backup_reload_child; " + "GRANT backup_reload_child TO backup_reload_parent; " + "GRANT backup_reload_parent TO 'app_backup_limited'@'localhost';") + self.addCleanup(self.sql, 'DROP ROLE backup_reload_parent; DROP ROLE backup_reload_child') + failed=self.run_script('app-backup.sh','--check',ok=False) + self.assertIn('global RELOAD privilege missing',failed.stdout) + self.sql("SET DEFAULT ROLE backup_reload_parent FOR 'app_backup_limited'@'localhost'") + passed=self.run_script('app-backup.sh','--check') + self.assertIn('confirmed through an enabled role',passed.stdout) + self.assertNotIn('systemctl stop',self.events.read_text()) + self.backup() + + def test_missing_routine_metadata_access_blocks_before_maintenance(self): + self.restricted_backup_account() + self.sql("GRANT RELOAD ON *.* TO 'app_backup_limited'@'localhost'; " + "REVOKE SELECT ON mysql.proc FROM 'app_backup_limited'@'localhost';") + failed=self.run_script('app-backup.sh','--check',ok=False) + self.assertIn('SELECT on mysql.proc required',failed.stdout) + self.assertNotIn('systemctl stop',self.events.read_text()) + self.assertEqual(json.loads(self.state.read_text()),self.initial) + self.sql("GRANT SELECT ON mysql.proc TO 'app_backup_limited'@'localhost'; " + "CREATE PROCEDURE backup_test.backup_probe() SELECT 1;") + run=self.backup() + with subprocess.Popen(['zstd','-dc',str(run/'db-wordpress.tar.zst')],stdout=subprocess.PIPE) as proc: + with tarfile.open(fileobj=proc.stdout,mode='r|') as archive: + routine_dump=archive.extractfile(archive.next()).read() + self.assertIn(b'PROCEDURE `backup_probe`',routine_dump) + def test_missing_mail_store_requires_explicit_resolution(self): canonical=self.configure_upgrade() with canonical.open('a') as f: f.write(f'MAIL_DIR="{self.src}/missing-mail"\n')