Skip to content

feat(sources): hardened the s3, postgres and mysql sources - #16

Merged
karlspace merged 22 commits into
mainfrom
fix/sources-hardening
Oct 9, 2026
Merged

karlspace merged 22 commits into
mainfrom
fix/sources-hardening

Conversation

@karlspace

@karlspace karlspace commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Gaps in the engine's built-in sources, found in the consumers or in the first real-database CI run of this PR:

  1. S3 source dropped content headers. Only Content-Type was captured. Content-Disposition, Cache-Control, Content-Encoding and Content-Language were lost on restore. CS-Outline stores attachments of types a browser would render (HTML, SVG) with Content-Disposition: attachment; after a restore they were rendered on Outline's origin instead of being downloaded.
  2. One unreadable tag set failed the whole S3 mirror. Credentials without s3:GetObjectTagging, or a store without object tagging (NotImplemented), left the job with no copy of the bucket at all.
  3. S3 tags were not URL-encoded on restore. Tagging is a query string, so lang=c++ came back as lang=c (reproduced with moto).
  4. No TLS options for S3. An endpoint with a private-CA certificate could only be trusted through the process-wide AWS_CA_BUNDLE.
  5. Postgres restore over partitioned tables always rolled back. The clean phase of pg_restore --clean drops each partition's primary key on its own, and PostgreSQL refuses (cannot drop inherited constraint). CS-IAM ships the zitadel-postgres plugin only for this; CS-BillingStack runs postgresql-partman.
  6. Postgres grants were always dropped. Dump and restore both use --no-acl, so a restricted runtime role lost its GRANTs (CS-IAMStack re-grants them in a post_restore hook).
  7. No MySQL default login. Alpine strips every client auth plugin from mariadb-client, and the image did not install mariadb-connector-c, which provides them. Logins with caching_sha2_password failed, and that is the only default on MySQL 8.4+ and 9+.
  8. No dump of MySQL 26.x. mariadb-dump takes any server version ≥ 10.3 for MariaDB and runs SHOW PACKAGE STATUS, which fails on MySQL 26.7 with error 1064. Seen in run 1, latest leg.
  9. No workflow ran scripts/e2e.sh. Its minio/mc image no longer exists on Docker Hub (404), so every "archive in MinIO" check failed in run 1.
  10. docs/sources.md gave "database" as the postgres name default. The code uses the database name.
  11. (review) The MySQL 26+ routines pass could drop a routine silently. It runs with --force and accepted every stderr line that did not say "error" or "couldn't". A routine whose body the backup user may not read is reported only as <user> has insufficient privileges to SHOW CREATE PROCEDURE …, so the backup ended in success without it.
  12. (review) The MySQL 26+ routines pass locked every table. Without --single-transaction, mariadb-dump runs LOCK TABLES … READ LOCAL on each table of the database, even with --no-data. That blocks writers, and a backup user without the LOCK TABLES grant (fine for the main pass) would fail the component with error 1044 (from the mariadb-dump source; not run).

Change

Commit What
fix(sources): preserved content headers of mirrored s3 objects The four headers go into metadata.json (null when absent) and are re-applied by put_object. Older snapshots restore as before.
fix(sources): kept s3 objects whose tags cannot be read On AccessDenied / NotImplemented / MethodNotAllowed / NotSupported / … or a bare HTTP 403/405/501, the object is kept with tags: null. The component carries one aggregated warning, so the job ends in warning with an alert. These objects restore without tags. Any other tagging error still fails the component.
fix(sources): url-encoded s3 object tags on restore urlencode(..., quote_via=quote)
feat(s3): added TLS verification settings for s3 endpoints verify_tls (default true) and ca_bundle (default null) on the s3 source and destination, with a shared S3ConnectionConfig and client builder (net/s3.py). When neither is set, boto3 gets verify=None as before, so AWS_CA_BUNDLE still applies. verify_tls: false logs a warning naming the endpoint on every build. The security warning is in the docs.
fix(sources): restored postgres dumps over partitioned tables Before a custom-format restore, one catalog query lists the target's top-level partitioned tables. If the dump recreates any of them, the restore runs as one psql --single-transaction: DROP TABLE … CASCADE for exactly those tables, then the dump's own clean-and-create script (generated into a file first). Any error rolls back everything. CASCADE notices are logged. Without partitioned tables, and for plain dumps, the restore runs the same pg_restore / psql command as before.
feat(sources): added an opt-in to keep postgres privileges keep_acl (default false) drops --no-acl from dump and restore. Such components carry "acl": true in their manifest metadata.
docs(sources): corrected the postgres name default Also documents exclude_table_data.
fix(sources): shipped the client authentication plugins mariadb-connector-c in the runtime and test stages, which adds the caching_sha2_password, sha256_password, client_ed25519, parsec and dialog plugins.
fix(sources): dumped mysql 26+ routines in a second pass A SELECT VERSION() probe runs first. On MySQL 10+ (no -MariaDB in the version), the main pass uses --skip-routines and a second pass (--routines --no-data --no-create-info --force) adds functions and procedures. In that second pass the package-query error is the only one accepted. Both passes go into one .sql.gz. MariaDB and MySQL 8/9 keep the single pass.
test: … (3) The e2e covers everything above against real servers. mc now comes from the CS-MinIO init image. The CodeQL finding (TLS 1.0 allowed on the test HTTPS server) is fixed.
ci: ran the e2e round trips against real databases New job 🧪 E2E Round Trip with legs compose (Dependabot-tracked images), lts (mariadb 11.4, mysql 8.4) and latest. The release job needs it. Least privilege, 30-minute timeout.
docs: listed the database versions the e2e round trips cover README, sources, deployment and restore now name the tested versions.
fix(sources): failed the MySQL 26+ dump on unreadable routines Review fix. The second pass now fails closed: it is accepted only if every stderr line is the package query or one of the client's notices (the TLS notice for a password from MYSQL_PWD, the deprecated mysqldump name). Anything else fails the component.
fix(sources): kept the MySQL 26+ routines pass free of table locks Review fix. --single-transaction in the second pass too: no table locks, and the routines come from one consistent snapshot.
test: covered least-privilege MySQL backup users in the e2e bk_min (SELECT, SHOW VIEW, TRIGGER, EVENT + SHOW_ROUTINE, no LOCK TABLES) must back up and restore the procedure. bk_exec (EXECUTE, no SHOW_ROUTINE) must fail with "insufficient privileges".
docs(sources): listed the privileges a MySQL backup user needs New "Backup user" paragraph.
docs: described the e2e coverage per CI leg instead of versions sources, deployment and restore name the legs (compose file, LTS 11.4/8.4, newest releases) instead of versions that go stale when Dependabot moves the compose file.
docs(sources): noted the temp space of a partitioned-table restore That path writes the whole database as uncompressed SQL to /tmp (a tmpfs in the recommended hardening).
docs(destinations): noted the per-request urllib3 TLS warning With verify_tls: false, urllib3 warns on every request (about two per object for an s3 source).

How verified

  • Unit tests: 413 pass locally (Python 3.14, Windows) and in CI (🧪 Pytest). New tests:
    • S3 header round trip, tagging refusals (parametrised codes), special-character tags (all on moto)
    • TLS against a real local HTTPS server with a throw-away CA: rejected by default, accepted with ca_bundle, accepted with verify_tls: false
    • the postgres command sequence: drops only live partitioned tables that are in the dump, single transaction, every failing step, plain dumps untouched, keep_acl on every path
    • MySQL 26 detection and the two-pass dump; (review) the client notices are accepted, an insufficient-privileges, LOCK TABLES or transaction report fails the dump, and the routines pass carries --single-transaction. The insufficient-privileges, transaction and lock-flag tests failed against 037d87d.
    • The new regression tests were checked to fail against the old code.
  • Real-database round trips: on the final head b08377b, run 4: 28/28 checks on every leg (compose, lts, latest). Before the review fixes: run 2 and run 3, 25/25 on every leg. Backups and restores run in the engine image with its own clients (pg_dump 18.6, mariadb-dump 11.8.8).
    • compose: PostgreSQL 18.6, MariaDB 11.8.9, MySQL 8.0.46
    • lts: MariaDB 11.4.13, MySQL 8.4.11
    • latest: MariaDB 13.0.2, MySQL 26.7.0
    • PostgreSQL scenarios:
      • restore into an empty database
      • restore in place over a live partitioned table (the CS-IAM failure)
      • with keep_acl, a restricted role keeps SELECT / INSERT
      • the default still drops grants
    • MariaDB / MySQL scenarios:
      • 4-byte UTF-8 text, a trigger and a stored procedure survive the round trip
      • MySQL logs in as a fresh caching_sha2_password user after FLUSH PRIVILEGES, so every login takes the full exchange
      • (review) a backup user without LOCK TABLES (bk_min) backs up, and its snapshot restores the procedure, on MySQL 8.0.46, 8.4.11 and 26.7.0 (on 26.7 that includes the second pass)
      • (review) a user who sees the procedure only through EXECUTE (bk_exec) ends in error on all three. The leg logs show the real stderr; on 26.7.0 it is the deprecated-name notice, the TLS notice, mysqldump: bk_exec has insufficient privileges to SHOW CREATE PROCEDURE `demo_count`! and the package error. Fed to the classifier of 037d87d, these lines are accepted (the procedure would have been dropped silently); the new classifier rejects them.
    • S3 scenarios:
      • headers, metadata and URL-special tags come back
      • a MinIO user without s3:GetObjectTagging backs up every object, ends in warning with get_object_tagging: AccessDenied, and restores untagged
  • Failures that led to fixes: run 1 showed the MySQL 26.7 SHOW PACKAGE STATUS failure, the missing minio/mc image and a trigger seeded with a root DEFINER (test setup only, docs now explain the DEFINER rule). The run also showed that the plugin fix works: MySQL 8.0 and 8.4 caching_sha2_password round trips already passed there.
  • Lint and compose: GitGuardian, CodeQL, ShellCheck, compose validation and the PR image build are green. Locally: actionlint, shellcheck -S warning, yamllint relaxed, and docker compose -f docker-compose.e2e.yml -f <override> config (render only).

Not verified

  • TLS against a real MinIO over HTTPS. It was tested against a local Python HTTPS server only.
  • The partition-safe restore on Zitadel's real schema, and on a pg_partman schema with many partitions. The lock count in one transaction rises with the partition count.
  • Restore of an S3 snapshot to a store without object tagging.
  • arm64 images.
  • (review) That the old routines pass fails for a user without LOCK TABLES was not run. It follows from mariadb-dump 11.8 (lock_tables=1 by default, cleared only by --single-transaction or --lock-all-tables; LOCK TABLES … READ LOCAL in dump_all_tables_in_db). The fixed pass was run with such a user.
  • The least-privilege e2e covers MySQL only, not MariaDB (whose routine-body privilege differs). The postgres e2e user is a superuser, so keep_acl and the partitioned-table restore are not verified as a non-superuser owner.

Impact / upgrade steps for existing deployments

  • No config change is needed, and every default keeps its behaviour: keep_acl: false, verify_tls: true, ca_bundle: null, and the single-pass dump for MariaDB and MySQL 8/9.
  • Postgres: a custom-format restore runs one extra catalog query first. Only when the target holds partitioned tables that the dump recreates does it take the new path. Before this change that case always failed.
  • S3: new snapshots record four more header fields and record tags: null for objects whose tags could not be read. Jobs whose credentials lack s3:GetObjectTagging move from error (no snapshot) to warning (snapshot without tags).
  • MariaDB / MySQL: every dump opens one extra short connection (SELECT VERSION()). The image grows by mariadb-connector-c (404 KiB installed on Alpine 3.24).
  • MySQL 26+ backup users (1.7.7 could not back up MySQL 26 at all, so nothing regresses): they need no LOCK TABLES, but must be able to read routine bodies (SHOW_ROUTINE or global SELECT, or be the definer). Otherwise the component fails with "insufficient privileges", as the single pass on MySQL 8/9 always did.
  • Consumers that can drop workarounds after the release:
    • CS-IAM can switch from zitadel-postgres back to type: postgres.
    • CS-IAMStack can set keep_acl: true for the grants, but still needs its hook to create the roles and passwords on a new host.
  • Merge method: please squash-merge (the title is now feat, matching the two new options) or rebase-merge. 2b45e27 and e470f49 add the e2e gate before the fixes that make it pass, so a rebase-merge puts those two red commits on main.
  • Releases are now gated on the e2e matrix. Because of the latest leg, a new MariaDB or MySQL release the engine cannot back up blocks the next engine release. If that is unwanted, mark the leg continue-on-error.

Dependabot PRs (evaluated with this PR's e2e, not merged)

  • chore(deps): bump mariadb from 11 to 13 #7 mariadb 11 → 13: safe to merge. MariaDB 13.0.2 (the same image mariadb:13 resolves to) passed the full round trip with the engine's mariadb-dump 11.8.8 in the latest leg. This includes utf8mb4 text, a trigger and a procedure. Merge after this PR, so the rebased Dependabot PR runs the compose leg on 13 as well.

  • chore(deps): bump mysql from 8.0 to 26.7 #6 mysql 8.0 → 26.7: not safe before this PR. Engine 1.7.7 cannot back up MySQL 26.7 for three reasons:

    • there is no caching_sha2_password plugin
    • mariadb-dump fails on SHOW PACKAGE STATUS (see run 1, latest leg)
    • the e2e compose forces --default-authentication-plugin, which MySQL 8.4 removed according to its release notes (not run here)

    With this PR, MySQL 26.7.0 passes the full round trip (latest leg). It is safe to merge after this PR, once Dependabot has rebased it and its compose e2e leg is green.

Follow-ups

  • CS-IAM: replace the zitadel-postgres plugin with type: postgres once this is released, and keep its round trip as proof.
  • CS-IAMStack: consider keep_acl: true. The role/password repair hook stays.
  • MySQL/MariaDB TLS: the client prints option --ssl-verify-server-cert is disabled, because of an insecure passwordless login. It does not see the MYSQL_PWD password, so server certificates are not verified. A verify_tls / ca_bundle pair for the MySQL-family sources (like S3) is a separate change. A future client that verifies here could also refuse the plain-text caching_sha2_password exchange over an unverified connection.
  • An upstream MariaDB bug report: mariadb-dump's >= 100300 version checks misfire on MySQL's calendar versions.
  • Release gate (needs a decision): the daily base-image rebuild dispatches this workflow, so a new MariaDB/MySQL release the engine cannot dump would also block the security rebuilds. Option: keep compose and lts as the gate and run latest as a non-gating job with an alert.
  • If verify_tls: false is used in earnest: suppress urllib3's per-request InsecureRequestWarning after the engine's own warning (process-wide, so left out here).
  • S3 Content-Encoding is replayed verbatim; consider dropping aws-chunked should a store ever report it.
  • The partitioned-table TOC match uses a name prefix ("<schema> <name> "); a table named like events x next to events could match the wrong entry (same limitation as the CS-IAM plugin).
  • e2e gaps: MySQL 26 views, events and functions, a MySQL server without TLS (RSA key exchange), and a non-superuser postgres owner.

The s3 source captured only Content-Type besides user metadata and
tags, so a restore dropped Content-Disposition, Cache-Control,
Content-Encoding and Content-Language. Outline stores attachments of
types a browser would render (HTML, SVG) with "Content-Disposition:
attachment"; after a restore those files were rendered on Outline's
origin instead of being downloaded.

* metadata.json now records the four headers per object (null when
  the object has none)
* restore passes them back to put_object
* snapshots taken before this change lack the keys and restore as
  before, with Content-Type only
A single refused get_object_tagging call failed the whole s3 mirror:
credentials without s3:GetObjectTagging, or a store without object
tagging (NotImplemented), left the job with no copy of the bucket at
all although every object was readable.

* AccessDenied, NotImplemented, MethodNotAllowed, NotSupported,
  UnsupportedOperation, XNotImplemented or a bare HTTP 403/405/501
  now keep the object and record its tags as null in metadata.json
  ({} still means "no tags")
* the component carries one aggregated warning with the count and
  the error codes, so the job ends in warning with an alert instead
  of silently losing tags
* those objects restore without tags; any other tagging error (a
  server error, a timeout) still fails the component
Restore built the Tagging parameter by joining key=value pairs
without encoding. Tagging is a URL query string, so S3 and MinIO
decode "+" as a space: a tag "lang=c++" came back as "lang=c  ", and
a space or "=" inside a key or value garbled the tag.

The tags are now encoded with urlencode/quote, which round-trips every
character S3 allows in a tag.
Self-hosted MinIO or Ceph endpoints often present a certificate from
an internal CA. The s3 source and destination had no way to trust it
other than the process-wide AWS_CA_BUNDLE variable, and no way to
connect at all to an endpoint whose certificate cannot be verified.

* verify_tls (default true) and ca_bundle (default null) on the s3
  source and the s3 destination
* ca_bundle points boto3 at a PEM file of CAs to trust; a missing
  file is a clear error instead of a TLS failure on the first call
* verify_tls false switches verification off and logs a warning on
  every client build that names the endpoint and the risk
* with neither set, boto3 receives verify=None as before, so its own
  default and AWS_CA_BUNDLE keep working unchanged
* both configs now share S3ConnectionConfig and one client builder
  (net/s3.py) instead of two copies of the same boto3 setup

Tests run real handshakes against a local HTTPS server with a
throw-away CA: rejected by default, accepted with ca_bundle, accepted
with verify_tls false.
A custom-format restore ran pg_restore --clean --if-exists in one
transaction. Its clean phase drops each partition's primary key on its
own, which PostgreSQL refuses ("cannot drop inherited constraint"),
so a restore into a database holding partitioned tables rolled back
completely. CS-IAM had to ship its own zitadel-postgres source for
Zitadel's partitioned cache tables; the engine now does it for every
consumer.

* before a custom restore, one catalog query lists the target's
  top-level partitioned tables (NUL-separated, so any name parses)
* only when the dump recreates some of them (matched against
  pg_restore --list), the restore runs as one psql
  --single-transaction: DROP TABLE ... CASCADE for exactly those
  tables, then the dump's own clean-and-create script, generated into
  a file first so a half-written script is never executed
* any error rolls back everything, the drops included; psql's
  "drop cascades to" notices are logged, and an error message shows
  the ERROR lines rather than the notices before them
* without partitioned tables in the target, and for plain dumps, the
  restore is exactly the pg_restore / psql call it was before
The postgres source dumped and restored with --no-acl. --clean drops
and recreates every object, so after a restore an application that
connects as a restricted runtime role lost its GRANTs and failed with
"permission denied"; CS-IAMStack had to re-grant them in a
post_restore hook.

* keep_acl (default false) leaves out --no-acl on pg_dump (custom and
  plain) and on the restore, including the partition-safe path, so
  GRANT/REVOKE and ALTER DEFAULT PRIVILEGES survive the round trip
* components dumped with it carry "acl": true in their manifest
  metadata, so show tells which snapshots hold privileges
* the default is unchanged: --no-acl both ways, as before

Roles are cluster objects and stay outside the dump; the docs spell
out that every granted role must exist on the target.
The postgres table listed "database" as the default component name.
The source names its component after the database (name, else
database), and restore matches the component by that name, so the
documented default sent operators looking for a database.dump that
is never written.

Also documented exclude_table_data, which the source accepts but the
table did not list.
Alpine's mariadb package deletes every client authentication plugin
from mariadb-client because mariadb-connector-c installs the same files
into /usr/lib/mariadb/plugin, yet mariadb-client does not depend on
it. The image therefore had none of them: a login to a MySQL user with
the default caching_sha2_password failed with "Plugin
caching_sha2_password could not be loaded", which rules out every
default MySQL 8.4+ server (mysql_native_password is off in 8.4 and
removed in 9.0) and MariaDB users on ed25519 or PARSEC.

The image now installs mariadb-connector-c next to mariadb-client, in
the runtime and the test stage. It adds the caching_sha2_password,
sha256_password, client_ed25519, parsec and dialog plugins; a
mysql_native_password user keeps working as before.
scripts/e2e.sh now covers what the unit tests can only fake, against
real servers and through the engine image's own clients:

* postgres: a partitioned table and a restricted runtime role with
  grants; restore into an empty database, in place over the live
  partitioned table, and with keep_acl (grants kept, "acl" marked in
  the manifest); the default still drops the grants
* mariadb/mysql: 4-byte UTF-8 text and a trigger survive the round
  trip; mysql logs in as a fresh caching_sha2_password user after
  FLUSH PRIVILEGES, so every login takes the full exchange instead
  of the healthcheck-warmed auth cache, and the compose service no
  longer forces mysql_native_password (gone in MySQL 9)
* s3: content headers, user metadata and URL-special tags come back
  (scripts/e2e_s3.py checks them); a user without
  s3:GetObjectTagging still backs up every object, ends in warning
  and restores them untagged
* E2E_POSTGRES_IMAGE / E2E_MARIADB_IMAGE / E2E_MYSQL_IMAGE swap the
  server images for one run without touching the Dependabot-tracked
  compose file; the run prints server and client versions
* failed checks print the command output, a failed run the service
  logs

Credentials of new users are generated per run; existing dev values
are reused from the script's own variables.
The e2e matrix (scripts/e2e.sh) existed but no workflow ran it, so a
restore path broken against a real server - partitioned tables, a
MySQL default login - reached a release unnoticed.

* new job "E2E Round Trip" in docker-release.yml, three legs:
  compose (the Dependabot-tracked images of docker-compose.e2e.yml),
  lts (mariadb 11.4, mysql 8.4) and latest (newest mariadb/mysql)
* the release job now needs it, next to pytest and the validations
* pull requests touching scripts/ or this workflow run it too
* least privilege (contents: read), 30-minute timeout
@karlspace karlspace changed the title fix(sources): hardened s3/postgres sources and ran real-database round trips in CI fix(sources): hardened the s3, postgres and mysql sources Oct 9, 2026
Comment thread tests/net/test_s3.py Fixed
CodeQL flagged the throw-away HTTPS server of the TLS verification
tests (py/insecure-protocol): ssl.PROTOCOL_TLS_SERVER still allows
TLS 1.0/1.1. The context now sets minimum_version to TLS 1.2; the
tests are unchanged otherwise.
mariadb-dump takes every server version from 10.3 up for MariaDB and,
with --routines, asks for MariaDB packages (SHOW PACKAGE STATUS).
MySQL's calendar versions (26.x) are above that, so the e2e run
against mysql:26.7 failed the whole dump with "Couldn't execute
'SHOW PACKAGE STATUS ...' (1064)" - no MySQL 26 backup was possible.

* before each dump the source reads SELECT VERSION(); a failed probe
  keeps the single pass, which then reports the real error
* on MySQL 10+ (no "-MariaDB" in the version) the main pass runs with
  --skip-routines, and a second pass (--routines --no-create-info
  --no-data --force) adds functions and procedures; only the package
  query error is accepted there (exit code 2), anything else fails
  the component
* both passes land in the same .sql.gz and restore as one; the
  component metadata says "routines": "separate pass (MySQL 26+)"
* MariaDB and MySQL 8/9 keep the single pass unchanged

The docs also note that a restore needs a user allowed to recreate
objects for their DEFINER.
The first CI run of the e2e matrix showed two problems in the script
itself:

* minio/mc no longer exists on Docker Hub, so every "archive in
  MinIO" check and the creation of the user without
  s3:GetObjectTagging failed; mc now runs from the CS-MinIO init image,
  which ships it, and a failed check prints the listing
* the MariaDB trigger was seeded by root but restored by the
  application user, which may not recreate objects for another
  DEFINER; the extras are now created by the application user, as an
  application would

The MariaDB and MySQL round trips also check a stored procedure now,
which exercises the MySQL 26+ routines pass, and a DB restore must
report "restore complete".
README, sources, deployment and restore still claimed "MariaDB 11/12,
MySQL 8/9" without any test behind it, and the restore guide said the
database paths were covered by unit tests only.

They now name what CI actually round-trips through the engine image
(MariaDB 11.4, 11.8 and 13; MySQL 8.0, 8.4 and 26.7; PostgreSQL 18
with partitioned tables and grants), and the README explains how to
run scripts/e2e.sh locally.
On MySQL 26+ the routines come from a second mariadb-dump pass run with
--force. That pass was accepted unless a stderr line other than the
package query said "error" or "couldn't". A routine whose body the
backup user may not read (MySQL hides it from a user holding only
EXECUTE or ALTER ROUTINE) is reported as "<user> has insufficient
privileges to SHOW CREATE PROCEDURE ..." - neither word - and --force
leaves it out. The backup then ended in success without the routine,
while the single pass on every other server fails the component for
the same condition.

The check now fails closed: the pass is accepted only if every stderr
line is the MariaDB package query or one of the client's notices (the
TLS notice for a password from MYSQL_PWD, the deprecated mysqldump
name). Any other report fails the component with the dump's stderr.

The new regression tests for the insufficient-privileges and the
transaction report failed against the old check. docs/sources.md
states the rule.
The second mariadb-dump pass on MySQL 26+ ran without
--single-transaction. mariadb-dump then keeps its default lock_tables
and issues LOCK TABLES ... READ LOCAL on every table of each dumped
database, even with --no-data, and holds the locks until the routines
are written. On a busy InnoDB database that blocks writers for the
length of the pass, and a least-privilege backup user without the
LOCK TABLES grant - fine for the main pass - failed the whole
component with error 1044.

The pass now runs with --single-transaction like the main pass: no
table locks, and the routines are read from one consistent snapshot.
A unit test asserts the flag; docs/sources.md describes the second
pass with its real flags and says it needs no LOCK TABLES privilege.
The MySQL round trip ran only as a user with ALL ON *.*, so it could
not show that a dump needs no LOCK TABLES privilege, or that a routine
the backup user may not read fails the backup - the two defects just
fixed in the MySQL 26+ routines pass.

Two more users now back up the same database on every leg:
* bk_min (SELECT, SHOW VIEW, TRIGGER, EVENT on the database and
  SHOW_ROUTINE, no LOCK TABLES): the backup must succeed, and
  restoring it must bring the procedure back.
* bk_exec (the same with EXECUTE instead of SHOW_ROUTINE): MySQL hides
  the procedure body, so the backup must end in error with
  "insufficient privileges" in its manifest. The error text goes to
  the log as evidence of what mariadb-dump reported.

Passwords are generated at run time, as for the existing bk user.
The mysql section never said which grants a dedicated backup user
needs, and the two routines fixes change the answer for MySQL 26+:
no pass needs LOCK TABLES any more, and a routine whose body the user
may not read now fails the dump instead of vanishing from it.

The new "Backup user" paragraph lists the grants (SELECT, SHOW VIEW,
TRIGGER, EVENT; SHOW_ROUTINE or global SELECT for routine bodies),
explains the hidden-body case and points at the e2e users that check
both sides.
sources, deployment and restore listed the exact MariaDB and MySQL
versions CI round-trips (11.4, 11.8, 13; 8.0, 8.4, 26.7). Two of them
only come from docker-compose.e2e.yml, which Dependabot moves: once
the open bumps to MariaDB 13 and MySQL 26.7 are merged, CI no longer
runs 11.8 or 8.0 and the docs would claim coverage that is gone.

They now name what each leg runs - the versions in
docker-compose.e2e.yml, the LTS lines MariaDB 11.4 and MySQL 8.4
(pinned in the workflow) and the newest releases - which stays true
whatever Dependabot merges. Every e2e run logs the exact versions.
A restore over live partitioned tables first writes the dump's
clean-and-create script to a file, so that psql never commits a
truncated script. That file is the whole database as uncompressed SQL
and lands in the restore's temporary directory - /tmp, which the
recommended hardening mounts as a tmpfs, i.e. memory. The docs did not
say so; an operator could only find out when a large restore ran out
of space.

The partitioned-tables paragraph now states the space it needs and
that script generation and apply each have the 4-hour restore
timeout.
With verify_tls false the docs said urllib3 "adds an
InsecureRequestWarning", which reads like a single line. urllib3
emits it for every request: five head_bucket calls against a local
HTTPS server gave five warnings. The s3 source makes two requests per
object (get_object, get_object_tagging), so a mirror floods the log.

The TLS section now says so. Suppressing the warning in the engine was
left out on purpose: it is process-wide, and the engine already logs
its own warning naming each unverified endpoint once per run.
@karlspace karlspace changed the title fix(sources): hardened the s3, postgres and mysql sources feat(sources): hardened the s3, postgres and mysql sources Oct 9, 2026
@karlspace
karlspace merged commit 2e36b14 into main Oct 9, 2026
19 checks passed
@karlspace
karlspace deleted the fix/sources-hardening branch October 9, 2026 10:29
@github-actions

github-actions Bot commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 1.9.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants