MySQL / MariaDB Integration Guide
This guide covers how to connect a MySQL or MariaDB database to BurnLedger, configure query templates, and set up a read-only database user.
Connection Configuration
BurnLedger connects to MySQL/MariaDB using a standard go-sql-driver DSN.
Format:
<user>:<password>@tcp(<host>:<port>)/<database>?tls=true
The @tcp(<host>:<port>) form is required. The host is extracted from between @tcp( and ) for SSRF validation at registration; a DSN in any other form (@unix(...), or a bare host:port) is rejected with BAD_REQUEST ("invalid MySQL DSN: cannot extract host").
Avoid
@ : / ( )in the password. A MySQL DSN is not a URI, so percent-encoding does not help — the driver splits the DSN on punctuation, and the registration-time host extractor looks for the literal@tcp(. A password containing any of those characters can move the apparent host boundary and cause either a parse failure or a host mismatch. Use a password limited to letters, digits,-and_.
Example:
burnledger_ro:REPLACE-WITH-YOUR-PASSWORD@tcp(db.example.com:3306)/myapp?tls=true
REPLACE-WITH-YOUR-PASSWORD is a placeholder, not a suggestion: choose your own password when you create the user below, and substitute it wherever the placeholder appears in this guide.
Configuration fields:
| Field | Required | Description |
|---|---|---|
dsn |
Yes | MySQL DSN connection string. |
TypeScript
const system = await bl.registerSystem({
name: "users-mysql",
connectorType: "mysql",
connectionConfig: {
dsn: "burnledger_ro:REPLACE-WITH-YOUR-PASSWORD@tcp(db.example.com:3306)/myapp?tls=true",
},
subjectQuery: "SELECT * FROM users WHERE email = ?",
});
Python
system = bl.register_system(
name="users-mysql",
connector_type="mysql",
connection_config={
"dsn": "burnledger_ro:REPLACE-WITH-YOUR-PASSWORD@tcp(db.example.com:3306)/myapp?tls=true",
},
subject_query="SELECT * FROM users WHERE email = ?",
)
Connection requirements:
| Requirement | Details |
|---|---|
| TLS | ?tls=true is required, and the host needs an explicit port (@tcp(db.example.com:3306)). Without a port the driver derives no ServerName on the production dial path and the config cannot be classified, which is refused. tls=skip-verify (encrypted but the certificate is never checked), tls=preferred (silently falls back to cleartext when the server clears one capability bit), a DSN with no tls= at all, and tls=custom (no custom TLS config is registered, so the DSN fails to parse) are all refused before the connector is built. For a private CA, supply the root as ca_cert alongside tls=true. Enforce TLS server-side too with REQUIRE SSL on the account. See connector transport security. |
| Network access | Add BurnLedger's static IPs to your firewall or security group. The DSN host must be publicly resolvable — localhost, .local/.internal names, and private/loopback addresses are rejected at registration. |
| Permissions | Read-only. BurnLedger refuses to connect if the user has write privileges. |
| Character set | UTF-8 (utf8mb4) is enforced: the connector appends charset=utf8mb4 to the DSN unless a charset parameter is already present. |
| Timeout | 10s connection, 30s query. |
Private CA (ca_cert)
tls=true verifies the server certificate against the system trust store. If
your server's verification record is signed by a private or internal CA, that check
fails with x509: certificate signed by unknown authority. Supply the root in
the connection config as ca_cert — base64 of the PEM bundle, snake_case in
both SDKs:
base64 -w0 ca.pem # macOS: base64 -i ca.pem | tr -d '\n'
{
"dsn": "burnledger_ro:pw@tcp(db.example.com:3306)/myapp?tls=true",
"ca_cert": "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0t..."
}
ca_cert does not change the transport classification: a custom trust root
is still verification, so the config stays verified and is admitted by the
ADR-012 floor. It also never turns TLS on — supplying it alongside a DSN
that puts no TLS on the data path is refused when the connector is built, rather
than connecting with a trust root that does nothing.
Keep tls=true: tls=skip-verify verifies nothing (a supplied root would be
inert), and a DSN with no tls= is refused outright when a ca_cert is present.
Query Template Format
MySQL query templates use ? as the parameter placeholder (standard MySQL prepared statement syntax).
Rules (enforced at registration; a template that fails any of them is rejected with INVALID_QUERY_TEMPLATE):
- Must begin with
SELECTorWITH(WITH … SELECTCTEs are accepted). - Must contain a
?placeholder. Without it the template is rejected — it is never silently run unparameterized. - Must contain a
WHEREclause. A template with noWHEREis rejected, not warned about. - Must be a single statement: no embedded
;. - Must not contain any write, DDL, locking, or session keyword anywhere in the text, including inside a CTE (
INSERT UPDATE DELETE REPLACE TRUNCATE DROP CREATE ALTER GRANT REVOKE INTO CALL EXECUTE LOAD LOCK SET PREPARE BEGIN COMMIT ROLLBACK, among others). - Must not use
FOR UPDATE/FOR SHARElocking clauses.
At execution time every query additionally runs inside a START TRANSACTION READ ONLY, so anything that slips past static validation still fails at the engine. SELECT * is allowed but produces a warning.
Examples:
-- Simple lookup
SELECT * FROM users WHERE email = ?
-- Join across tables
SELECT u.id, u.email, o.order_id, o.total
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE u.email = ?
-- Using a unique external ID
SELECT * FROM customers WHERE external_id = ?
Read-Only User Setup
-- Create a read-only user
CREATE USER 'burnledger_ro'@'%' IDENTIFIED BY 'REPLACE-WITH-YOUR-PASSWORD';
-- Grant SELECT on specific tables
GRANT SELECT ON myapp.users TO 'burnledger_ro'@'%';
GRANT SELECT ON myapp.orders TO 'burnledger_ro'@'%';
-- Or grant SELECT on all tables in the database
GRANT SELECT ON myapp.* TO 'burnledger_ro'@'%';
-- Apply
FLUSH PRIVILEGES;
Verify
-- Connect as read-only user
mysql -u burnledger_ro -p -h db.example.com myapp
-- Should succeed
SELECT * FROM users LIMIT 1;
-- Should fail with "INSERT command denied"
INSERT INTO users (email) VALUES ('test@test.com');
What the privilege check accepts
BurnLedger runs SHOW GRANTS FOR CURRENT_USER() — it never writes to verify. Each grant line's privilege list is parsed, and only SELECT and USAGE are accepted. Everything else marks the credential as write-capable and construction is refused with WRITE_ACCESS_DETECTED — including privileges that are not obviously writes:
ALL PRIVILEGES(caught by the same rule)CREATE TEMPORARY TABLES,LOCK TABLESPROCESS,RELOAD,REPLICATION CLIENT,REPLICATION SLAVE,SUPERSHOW VIEW,SHOW DATABASES,INDEX,REFERENCES,TRIGGER,EVENT
So grant the account SELECT and nothing else. Column-scoped grants (SELECT (col1, col2)) are handled correctly, and pure role grants (GRANT 'somerole' TO 'user'@'%', with no ON clause) are ignored — but any table privileges the role itself carries do show up in SHOW GRANTS once the role is activated, and are checked.
MariaDB
The same SQL applies. MariaDB uses the same privilege system as MySQL.
Amazon RDS / Aurora
Use the RDS console or CLI to create a user, then run the GRANT statements above. Ensure the security group allows inbound connections from BurnLedger's IPs.
Endpoint role
POST /v1/systems/test-connection reports, under replication, whether this
endpoint accepts writes: @@global.read_only and @@global.super_read_only,
which any account may read. read_only ON is how a replica in a managed
topology presents itself.
Replication lag is not reported, and will not be. Seconds_Behind_Source
comes from SHOW REPLICA STATUS, which needs REPLICATION CLIENT — one of the
privileges listed above that marks a credential write-capable and gets it
refused. Asking a customer to widen a grant so BurnLedger can print a nicer
diagnostic is the wrong trade, so the field says unknown and says why. A
server without super_read_only (MariaDB) also reports unknown. See
endpoint role.
Hash Scope Options
Same as PostgreSQL:
| Scope | Behavior |
|---|---|
full |
Hashes complete row content. Detects any change. |
existence |
Records only whether rows matched and how many. Detects insert/delete but not updates, and never reads column values. |
hash_scope defaults to existence when omitted.
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
CONNECTION_FAILED |
Cannot reach MySQL host | Verify DSN, check firewall, ensure TLS is enabled. |
WRITE_ACCESS_DETECTED |
The account holds any privilege other than SELECT/USAGE — not just INSERT/UPDATE/DELETE |
Revoke everything except SELECT: REVOKE ALL PRIVILEGES ON *.* FROM 'burnledger_ro'@'%'; GRANT SELECT ON myapp.* TO 'burnledger_ro'@'%'; Check with SHOW GRANTS FOR 'burnledger_ro'@'%';. |
INVALID_QUERY_TEMPLATE (HTTP 422) |
Query is not a single read-only SELECT, or is missing ? or WHERE |
Rewrite per Query Template Format. |
BAD_REQUEST "invalid MySQL DSN: cannot extract host" |
DSN is not in the user:pass@tcp(host:port)/db form |
Rewrite the DSN using @tcp(...). |
CONNECTION_FAILED on attestation: "connector mysql: transport security plaintext/encrypted is below the required minimum verified". A health check reports only connection failed — read transport_security on the system |
No tls= parameter, tls=false, tls=preferred (plaintext), or tls=skip-verify (encrypted, certificate never checked) |
Use ?tls=true. |
CONNECTION_FAILED on attestation: "connector mysql: transport security could not be classified and is never assumed secure; the required minimum is verified". A health check reports only connection failed — read transport_security on the system |
The DSN host has no explicit port (no ServerName can be derived), or tls=custom was used |
Add the port — @tcp(db.example.com:3306) — and use tls=true. |