Skip to content

Quote MySQL identifiers, and name the second place the engines differ - #36

Merged
sethbergman merged 1 commit into
mainfrom
fix/mysql-identifier-quoting
Aug 26, 2026
Merged

Quote MySQL identifiers, and name the second place the engines differ#36
sethbergman merged 1 commit into
mainfrom
fix/mysql-identifier-quoting

Conversation

@sethbergman

Copy link
Copy Markdown
Owner

Follow-up to #35, which merged while I was acting on a review of it.
Four findings, all nits, none of which broke the default path — three
worth fixing and one worth relabelling.

Identifier quoting

The MySQL grants interpolated ${DATABASE} bare while the Postgres
statements wrapped the same variable in double quotes:

GRANT SELECT ON ${DATABASE}.* TO '{{name}}'@'%';          -- before
GRANT SELECT ON `${DATABASE}`.* TO '{{name}}'@'%';        -- after

--database appdata is a bare identifier so nothing noticed, but a
hyphenated or reserved-word schema is a MySQL parse error at issuance
naming neither the flag nor the value.

The backticks are escaped, because a bare one inside a double-quoted bash
string starts command substitution. I flagged that trap when writing the
script and then walked into it applying the same quoting to the test
file — caught on the first run, and the assertions are escaped too.

The second asymmetry

The docs claimed VALID UNTIL was "the one place where the two engines
are not equivalent." That was false. Postgres readwrite grants
CREATE ON SCHEMA public; MySQL grants DML only. An ORM migration works
on one and fails on the other with CREATE command denied.

I did not take the obvious fix of granting MySQL the same DDL,
because it would not be equivalent — it would be broader:

Postgres MySQL
DDL is scoped by ownership — alter what you created, nothing else schema — every table in the database

GRANT CREATE, ALTER, DROP ON appdata.* would let every issued readwrite
credential drop the whole database. So the narrow grant stays, the
comparison table gains a row, and a workload that genuinely needs DDL
should get a third role rather than have readwrite widened underneath
every other consumer. Paired assertions pin it in both directions, the
way the VALID UNTIL pair already does.

Revocation was under-asserted

The MySQL check only confirmed the credential could no longer connect;
its Postgres neighbour also confirms the role is gone from pg_roles.

MySQL returns an identical "access denied" whether the password is wrong
or the account does not exist — so revoking grants without dropping
the user would have passed. It now reads mysql.user as root, whose
password this bootstrap never rotates, which is exactly what makes the
question askable (the issued credential is scoped to appdata precisely
so it cannot read mysql.user, and vaultadmin's password is gone by
then).

A relabel rather than a fix

The review called the vaultadmin assertions tautological and said the
intent "is not actually guarded." The guard is real — reintroducing the
root shortcut anywhere fails it, which a mutation confirms. But the
section header and labels claimed engine-specific behaviour they were not
testing, so they now say what they check: no engine connects as root.

One bug I introduced and caught

Adding the DDL test put a Postgres run immediately above the VALID UNTIL assertion, which reads the log of the preceding run. It
silently repointed at the wrong engine and started failing about MySQL
while describing Postgres. That section sets up its own run now instead
of inheriting whatever the previous one left behind.

Verification

Shim suite 57 → 60, all passing. Both new script behaviours are
mutation-verified: removing the identifier quoting and granting MySQL
schema-wide DDL are each caught by the assertion added for them.

Four findings from a review of #35, which merged while I was acting on
them. All nits, none of which broke the default path -- three worth
fixing and one worth relabelling.

IDENTIFIER QUOTING

The MySQL grants interpolated ${DATABASE} bare while the Postgres
statements wrapped the same variable in double quotes. `--database
appdata` is a bare identifier so nothing noticed, but a hyphenated or
reserved-word schema is a MySQL parse error at issuance that names
neither the flag nor the value. Backtick-quoted now, escaped because a
bare backtick in a double-quoted bash string starts command
substitution -- which is exactly what happened when the same quoting went
into the test file, and is why the assertions there are escaped too.

THE SECOND ASYMMETRY

The docs claimed VALID UNTIL was the one place the engines are not
equivalent. It was not: the Postgres readwrite role grants CREATE ON
SCHEMA public and the MySQL one grants DML only, so an ORM migration
works on one and fails on the other.

This one is not quietly fixable. Postgres ties DDL to ownership -- a
credential can alter what it created and nothing else. MySQL grants per
schema, so the same privilege would let every readwrite credential drop
the whole database. The narrower grant stays, the comparison table gains
a row, and a test asserts the difference in both directions the way the
VALID UNTIL pair already does.

That test pins the whole privilege list rather than naming the DDL
keywords to reject. The first version did the latter and was circular:
it caught exactly the string I had thought to write down, and the
mutation I "verified" it with added that same string. Granting only
CREATE walked straight through it -- confirmed by running the old
assertion against that mutation and watching it pass. Anchoring on
"DELETE ON" instead means any addition to the list breaks the match,
whichever privilege it is.

REVOCATION WAS UNDER-ASSERTED

The MySQL revocation check only confirmed the credential could no longer
connect, where its Postgres neighbour also confirms the role is gone from
pg_roles. MySQL returns an identical "access denied" whether the password
is wrong or the account does not exist, so revoking grants without
dropping the user would have passed. It now reads mysql.user as root --
whose password this bootstrap never rotates, which is what makes the
question askable at all.

A RELABEL RATHER THAN A FIX

The review flagged the vaultadmin assertions as tautological, since
USERNAME defaults to vaultadmin for both engines and no branch sets root.
The guard is real -- reintroducing the root shortcut anywhere fails it,
which a mutation confirms -- but the section header and labels claimed
engine-specific behaviour they were not testing. Relabelled to say what
they check: no engine connects as root.

Also fixes a state-inheritance bug introduced while adding the DDL test.
The VALID UNTIL assertion reads the log of the preceding run, so adding a
postgres run above it silently repointed it at the wrong engine and it
began failing about MySQL while describing postgres. It sets up its own
run now instead of inheriting whatever the previous section left.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@sethbergman
sethbergman force-pushed the fix/mysql-identifier-quoting branch from 3bdf27d to d86cb3d Compare August 26, 2026 02:55
@sethbergman

Copy link
Copy Markdown
Owner Author

CI

23/23 on d86cb3d. Both new assertions confirmed to have actually run,
not skipped:

shim         PASS  a hyphenated database name is quoted, not a parse error
shim         PASS  mysql readwrite grants DML and nothing more
integration  PASS  the revoked MySQL credential can no longer connect
integration  PASS  and the account is dropped from mysql.user entirely

Shim suite 57 → 60, integration 89 → 92.

A circular assertion I wrote, and how it was caught

Worth recording, because the first version of the DDL test looked fine
and proved nothing.

It named the keywords to reject:

assert_log_lacks "mysql readwrite gets no schema-wide DDL" "CREATE, ALTER, DROP"

And I "verified" it with a mutation that granted CREATE, ALTER, DROP
the exact string the assertion looks for. The mutation was built to match
the assertion, so of course it was caught. What it actually proved was
that grep works.

A grant of just CREATE walks straight through, because
SELECT, INSERT, UPDATE, DELETE, CREATE ON does not contain
CREATE, ALTER, DROP.

I checked that rather than reasoning about it — restored the old
assertion, applied a CREATE-only mutation, ran the suite:

RESULT: the OLD assertion SURVIVES the CREATE-only mutation — it was circular

The replacement pins the whole privilege list instead of enumerating what
to exclude:

assert_log_has "mysql readwrite grants DML and nothing more" \
    "GRANT SELECT, INSERT, UPDATE, DELETE ON \`appdata\`.*"

Anchoring on DELETE ON means any privilege appended to the list
breaks the match, whichever one it is — no need to have guessed it in
advance. Same CREATE-only mutation now fails the suite.

The general lesson, which applies to the other assert_log_lacks checks
in this file: an exclusion assertion is only as good as the author's
imagination, and a mutation written to match one is self-fulfilling.
Pinning the positive form is stronger where the value is small and
enumerable.

Note on why this is a separate PR

#35 merged while I was working these findings. GitHub auto-deleted the
branch, so my push recreated it as an orphan with no open PR — the
[new branch] line in the push output was the only sign. CI was green on
a branch attached to nothing. Moved here and the stray branch deleted.

@sethbergman
sethbergman merged commit e1c2f30 into main Aug 26, 2026
23 checks passed
@sethbergman
sethbergman deleted the fix/mysql-identifier-quoting branch August 26, 2026 03:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant