Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

MySQL & MariaDB in Depth  Module 12 – Security and access control

Privileges, roles and least privilege in MySQL

Split MySQL privileges into static and dynamic, group them into roles, and keep admin accounts safe with partial revokes, SYSTEM_USER and definer views.

  • Advanced
  • 20 minutes
  • Examples run with MySQL 8.4.11, Python 3.14.8 and Pyodide 314.0.7
  • By MySmartCoPilot
Free core, Pro depth: 7 of 20 minutes free
Free: Static privileges live in columns, dynamic ones in rows · Five accounts for five jobs, with only the grants of their work · A role groups privileges; the account holds the role. With a pass: 2 in-depth parts and 3 interview answers, about 13 minutes.

What you will learn

  • Distinguish static and dynamic privileges and name the two tables that store them
  • Design five accounts for five jobs on one server, each with least privilege
  • Configure roles for accounts, set a default role and read SHOW GRANTS ... USING
  • Limit a global grant with a partial revoke on one schema and protect admins with SYSTEM_USER
  • Query information_schema and the mysql tables to find who can do what, and prevent orphaned definer views

Before you start

What you’ll learn · Before you start

What you will learn

  • Distinguish static and dynamic privileges and name the two tables that store them
  • Design five accounts for five jobs on one server, each with least privilege
  • Configure roles for accounts, set a default role and read SHOW GRANTS ... USING
  • Limit a global grant with a partial revoke on one schema and protect admins with SYSTEM_USER
  • Query information_schema and the mysql tables to find who can do what, and prevent orphaned definer views

Before you start

On this page

MySQL protects data through privileges: fine-grained rights that attach to accounts. A new 8.4.11 server has thirty-one static privileges as _priv columns of mysql.user, and root holds forty dynamic ones as rows of mysql.global_grants, most registered by the server at startup. Least privilege in MySQL means each account carries only the grants its one job needs, scoped to the hosts it logs in from. Five accounts, from checkout to backup and the replication connection, each hold the privileges of one job.

A role: A named account in mysql.user with account_locked=Y and no password that groups privileges together; grant the role to accounts, activate it per session with SET ROLE or at login with a default role. is a locked account with no password that groups privileges together; granting the role to a human or service keeps the account itself unchanged when the job changes. Three more features make least privilege: The design rule that every account carries only the privileges it needs for its job, scoped to the hosts it logs in from, with wider grants replaced by schema- or table-level ones and risky dynamic privileges held only by named accounts. hold up on a real server: a partial revoke: A REVOKE at the schema level that narrows one account's global grant, so that, for example, a global SELECT leaves out one schema. keeps a global SELECT out of one schema, the SYSTEM_USER privilege: A dynamic privilege that marks an account as a system account, which an ordinary administrator without SYSTEM_USER cannot modify, so the holders of SYSTEM_USER form the server's smaller administrator set. privilege shields an admin account from an ordinary administrator, and a SQL SECURITY DEFINER view exposes aggregates without the base rows. Recorded runs of all three follow, with queries that find over-broad grants.

Static privileges live in columns, dynamic ones in rows

The first table of privileges_session.py counts the static privilege: A privilege built into the MySQL server and always available to grant; its global and schema grants are Y/N columns of mysql.user and mysql.db, its table and column grants sets in mysql.tables_priv and mysql.columns_priv. MySQL 8.4 holds at the global level: thirty-one, one _priv column each in mysql.user. The second counts the forty dynamic privilege: A privilege registered at run time, most by the server at startup and the rest by the component or plugin that defines it; held per account as a row of mysql.global_grants and granted only at the global level, ON *.*. rows that root@localhost holds in mysql.global_grants, each a named right such as BACKUP_ADMIN, FLUSH_TABLES or SYSTEM_USER. The split matters because the two stores answer different audit queries and follow different rules for where a grant may apply, which the paragraph after the figure makes precise.

Static and dynamic privileges on a new MySQL 8.4.11 server

privileges_session.py

"""Static and dynamic privileges on a throwaway MySQL 8.4.11 server.

privileges.sql counts the two kinds on a new server, then grants a dynamic privilege at a schema and at the global
level, grants one that no loaded plugin has registered, and grants the deprecated SUPER. The client runs on past the
two errors (--force), so every statement of the script prints what it did.
"""
import os

os.chdir(os.path.dirname(__file__) or ".")

from mysql_session import Server

with Server() as my:
    my.up()
    my.mysql("privileges.sql", errors=2)

Output

$ mysql -t -vvv --skip-comments --force < privileges.sql
--------------
SELECT COUNT(*) AS static_global_columns
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = 'mysql' AND TABLE_NAME = 'user' AND COLUMN_NAME LIKE '%\_priv'
--------------

+-----------------------+
| static_global_columns |
+-----------------------+
|                    31 |
+-----------------------+
1 row in set

--------------
SELECT COUNT(*) AS dynamic_held_by_root
FROM mysql.global_grants
WHERE USER = 'root' AND HOST = 'localhost'
--------------

+----------------------+
| dynamic_held_by_root |
+----------------------+
|                   40 |
+----------------------+
1 row in set

--------------
SELECT PRIV
FROM mysql.global_grants
WHERE USER = 'root' AND HOST = 'localhost'
  AND PRIV IN ('BACKUP_ADMIN', 'CONNECTION_ADMIN', 'FLUSH_TABLES', 'REPLICATION_APPLIER',
               'SYSTEM_USER', 'SYSTEM_VARIABLES_ADMIN')
ORDER BY PRIV
--------------

+------------------------+
| PRIV                   |
+------------------------+
| BACKUP_ADMIN           |
| CONNECTION_ADMIN       |
| FLUSH_TABLES           |
| REPLICATION_APPLIER    |
| SYSTEM_USER            |
| SYSTEM_VARIABLES_ADMIN |
+------------------------+
6 rows in set

--------------
CREATE DATABASE shop
--------------

Query OK, 1 row affected

--------------
CREATE USER 'backup_bot'@'localhost' ACCOUNT LOCK
--------------

Query OK, 0 rows affected

--------------
GRANT BACKUP_ADMIN ON shop.* TO 'backup_bot'@'localhost'
--------------

ERROR 3619 (HY000) at line 25: Illegal privilege level specified for BACKUP_ADMIN
--------------
GRANT BACKUP_ADMIN ON *.* TO 'backup_bot'@'localhost'
--------------

Query OK, 0 rows affected

--------------
GRANT FIREWALL_ADMIN ON *.* TO 'backup_bot'@'localhost'
--------------

ERROR 1149 (42000) at line 29: You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use
--------------
GRANT SUPER ON *.* TO 'backup_bot'@'localhost'
--------------

Query OK, 0 rows affected, 1 warning

--------------
SHOW WARNINGS
--------------

+---------+------+----------------------------------------------+
| Level   | Code | Message                                      |
+---------+------+----------------------------------------------+
| Warning | 1287 | The SUPER privilege identifier is deprecated |
+---------+------+----------------------------------------------+
1 row in set

--------------
SELECT PRIV FROM mysql.global_grants WHERE USER = 'backup_bot'
--------------

+--------------+
| PRIV         |
+--------------+
| BACKUP_ADMIN |
+--------------+
1 row in set

--------------
SELECT Super_priv FROM mysql.user WHERE User = 'backup_bot'
--------------

+------------+
| Super_priv |
+------------+
| Y          |
+------------+
1 row in set

--------------
SHOW GRANTS FOR 'backup_bot'@'localhost'
--------------

+-------------------------------------------------------+
| Grants for backup_bot@localhost                       |
+-------------------------------------------------------+
| GRANT SUPER ON *.* TO `backup_bot`@`localhost`        |
| GRANT BACKUP_ADMIN ON *.* TO `backup_bot`@`localhost` |
+-------------------------------------------------------+
2 rows in set

Bye

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 privileges_session.py

privileges.sql

-- Run as root on a new MySQL 8.4.11 server.

-- Static privileges are built into the server: each one of the global level has its own column in mysql.user.
SELECT COUNT(*) AS static_global_columns
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = 'mysql' AND TABLE_NAME = 'user' AND COLUMN_NAME LIKE '%\_priv';

-- Dynamic privileges are registered while the server runs: root holds each one as a row of mysql.global_grants.
SELECT COUNT(*) AS dynamic_held_by_root
FROM mysql.global_grants
WHERE USER = 'root' AND HOST = 'localhost';

SELECT PRIV
FROM mysql.global_grants
WHERE USER = 'root' AND HOST = 'localhost'
  AND PRIV IN ('BACKUP_ADMIN', 'CONNECTION_ADMIN', 'FLUSH_TABLES', 'REPLICATION_APPLIER',
               'SYSTEM_USER', 'SYSTEM_VARIABLES_ADMIN')
ORDER BY PRIV;

CREATE DATABASE shop;
-- A locked account: nobody logs in as it in this session, so it needs no password.
CREATE USER 'backup_bot'@'localhost' ACCOUNT LOCK;

-- A dynamic privilege exists only at the global level, ON *.*.
GRANT BACKUP_ADMIN ON shop.* TO 'backup_bot'@'localhost';
GRANT BACKUP_ADMIN ON *.* TO 'backup_bot'@'localhost';

-- FIREWALL_ADMIN belongs to the Enterprise firewall plugin, which this server has not loaded.
GRANT FIREWALL_ADMIN ON *.* TO 'backup_bot'@'localhost';

-- SUPER still works, and the server says what it thinks of it.
GRANT SUPER ON *.* TO 'backup_bot'@'localhost';
SHOW WARNINGS;

-- Where the two grants that worked were stored, and how SHOW GRANTS prints them.
SELECT PRIV FROM mysql.global_grants WHERE USER = 'backup_bot';
SELECT Super_priv FROM mysql.user WHERE User = 'backup_bot';
SHOW GRANTS FOR 'backup_bot'@'localhost';

mysql_session.py

"""Throwaway MySQL 8.4.11 servers for the examples of this lesson.

Every session creates a server of its own: `mysqld --initialize-insecure` makes a new data directory inside a new
folder in the examples folder, the server starts on it, the session runs `mysql` client scripts and MySQL's programs
against it and prints what they printed, and then the server is stopped and the folder deleted, also when something
fails. The server is the pinned MySQL 8.4.11 from the mise installations next to the Python that runs this file;
Server(basedir=...) runs the programs of another unpacked MySQL tarball instead, and tarball(name) finds one.

Safety: the server listens only on 127.0.0.1, an address no other computer can reach, on a port the operating system
reports as unused; the X Protocol listener is off (--mysqlx=OFF); its Unix socket, temporary files, error log and
import/export folder all stay inside the run folder. Right after the first start the session gives root a password made
up for the run (never printed) and connects only with a client option file that holds it, so no password ever appears
on a command line. Every program starts with --no-defaults or --defaults-file, so no option file of the computer
(/etc/my.cnf, ~/.my.cnf) can change a result, and HOME points at the run folder, so ~/.mylogin.cnf is not read either.
A printed mysqld command shows the options that matter to the lesson; the session also passes, on the command line,
the folders it keeps inside the run folder (--basedir, --tmpdir, --log-error, --pid-file, --secure-file-priv) and
the port, which is why performance_schema.variables_info reports those variables as COMMAND_LINE.
The sessions read only this folder and the pinned MySQL installations, and they connect to nothing but 127.0.0.1.

The output is exactly what the programs printed, with these substitutions so that a record never shows the computer it
ran on and is the same on every run: the run folder is shown as ".", the folder of MySQL's programs is left out of
their paths and MySQL's own folder is shown as <basedir>, the computer's name is shown as "lab-host" and the operating-system user's name as "learner", the time a
statement took (the client's "(0.01 sec)") is left out, the time stamp SET PERSIST writes into mysqld-auto.cnf is
shown as <when it was set>, error-log lines lose their timestamp and thread number, and a session can name more values
that change between runs with my.mask(pattern, replacement), which then prints in their place. A client table whose cells
change is drawn again so that its borders still line up.
The sessions never print the password, and the run's port prints as <port>. A session stops with status 1 when a command that should have worked
fails, or when a script reports a different number of errors than the session expects, so a run that went wrong is
never recorded as if it had worked.
"""

import os
import pwd
import re
import secrets
import shlex
import shutil
import socket
import subprocess
import sys
import tempfile
import textwrap
import time
from pathlib import Path

MYSQL_VERSION = "8.4.11"
# .../installs/python/3.14.8/bin/python3 -> .../installs
INSTALLS = Path(sys.executable).resolve().parents[3]
PINNED = INSTALLS / "mysql" / MYSQL_VERSION
OS_USER = pwd.getpwuid(os.geteuid()).pw_name
HOST = socket.gethostname().split(".")[0]
# 2026-10-17T16:50:01.123456Z 0 [ERROR] ... -> [ERROR] ...
LOG_PREFIX = re.compile(r"^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d+Z \d+ ", re.M)


def tarball(name):
    """The folder of an unpacked official tarball, such as mysql-9.7.2, for Server(basedir=...). It is the folder
    <name> in a folder called toolchains, looked for in every folder above the one the session runs in and above
    this file, directly or inside one of the folders of a tmp folder there. The search stops below the home folder."""
    home = Path(pwd.getpwuid(os.geteuid()).pw_dir).resolve()
    seen = set()
    for start in (Path.cwd().resolve(), Path(__file__).resolve().parent):
        for parent in (start, *start.parents):
            if parent in seen or parent == home or parent == Path(parent.anchor):
                break
            seen.add(parent)
            candidates = [parent / "toolchains" / name]
            if (parent / "tmp").is_dir():
                candidates += sorted((parent / "tmp").glob(f"*/toolchains/{name}"))
            for found in candidates:
                if (found / "bin").is_dir():
                    return found
    sys.exit(f"no toolchains folder above this one holds {name}")


def free_port():
    """A TCP port on 127.0.0.1 that nothing listens on now."""
    with socket.socket() as probe:
        probe.bind(("127.0.0.1", 0))
        return probe.getsockname()[1]


class Server:
    """One throwaway server: initialise, start; stopped and deleted when the with block ends.

    options: extra mysqld options for every start (for example "--sql-mode=" or "--character-set-server=latin1").
    basedir: the folder of an unpacked MySQL tarball to run instead of the pinned 8.4.11.
    masks: (pattern, replacement) pairs, as for mask().
    """

    def __init__(self, options=(), redo=None, basedir=None, masks=(), label=None, port_label=None):
        self.options = [str(o) for o in options]
        self.redo = redo
        self.basedir = Path(basedir).resolve() if basedir else PINNED
        self.bin = self.basedir / "bin"
        self.label = label                       # a stable name for this server in the record ("source", "replica")
        self.port_label = port_label             # the text that shows in place of this server's port
        self.uuid = None
        self.masks = []
        for pattern, replacement in masks:
            self.mask(pattern, replacement)
        self.proc = None

    def mask(self, pattern, replacement):
        """From now on every printed line shows text matching the regular expression as the replacement: for a value
        that changes from run to run (an uptime, a time stamp), so that the recorded output stays the same."""
        self.masks.append((re.compile(pattern), replacement))

    # ---- the folder ------------------------------------------------------------------------------------------------
    def __enter__(self):
        if not (self.bin / "mysqld").exists():
            sys.exit(f"no MySQL server program in {self.basedir}" if self.basedir != PINNED
                     else f"MySQL {MYSQL_VERSION} was not found next to this Python")
        self.base = Path(tempfile.mkdtemp(prefix="my", dir=".")).resolve()
        for sub in ("tmp", "files"):
            (self.base / sub).mkdir(mode=0o700)
        self.port = free_port()
        self.password = secrets.token_urlsafe(24)
        self.client_cnf = self.base / "client.cnf"
        self._write_client_cnf(password=None)
        self.env = {key: value for key, value in os.environ.items()
                    if not key.startswith("MYSQL") and key not in ("TZ", "HOME", "LANG", "LC_ALL")}
        self.env.update({"HOME": str(self.base), "TZ": "UTC", "LANG": "en_US.UTF-8",
                         "MYSQL_TEST_LOGIN_FILE": str(self.base / "no-login-paths.cnf")})
        for pattern in ("*.sql", "*.cnf", "*.csv", "*.txt"):
            for f in Path(".").glob(pattern):
                shutil.copy(f, self.base / f.name)
        return self

    def __exit__(self, *exc):
        if self.proc and self.proc.poll() is None:
            self.stop(quiet=True)
        shutil.rmtree(self.base, ignore_errors=True)

    def _write_client_cnf(self, password):
        lines = ["[client]", "user=root", "host=127.0.0.1", f"port={self.port}", "protocol=TCP"]
        if password:
            lines.append(f"password={password}")
        self.client_cnf.write_text("\n".join(lines) + "\n")
        self.client_cnf.chmod(0o600)

    def server_options(self):
        b = self.base
        return ["--no-defaults", f"--basedir={self.basedir}", f"--datadir={b / 'data'}", f"--tmpdir={b / 'tmp'}",
                f"--log-error={b / 'mysqld.err'}", f"--pid-file={b / 'mysqld.pid'}",
                f"--secure-file-priv={b / 'files'}",
                *([f"--innodb-redo-log-capacity={self.redo}"] if self.redo else [])]

    # ---- the server ------------------------------------------------------------------------------------------------
    def initialize(self, show=False):
        """mysqld --initialize-insecure: a new data directory whose root account has no password yet."""
        args = ["mysqld", *self.server_options(), "--initialize-insecure"]
        code, out = self._call(args)
        if show:
            _show(["mysqld", "--no-defaults", "--initialize-insecure", "--datadir=./data"], out)
        if code != 0:
            print(self.log())
            sys.exit(f"mysqld --initialize-insecure ended with status {code}")

    def start(self, extra=(), defaults_file=None, expect_fail=False, show=False):
        """Starts mysqld on 127.0.0.1 and waits until it answers. With expect_fail, prints the error-log lines of a
        start that must fail, and returns them."""
        log = self.base / "mysqld.err"
        before = log.read_text() if log.exists() else ""
        opts = self.server_options()
        if defaults_file:
            opts[0] = f"--defaults-file={self.base / defaults_file}"
        args = [str(self.bin / "mysqld"), *opts, "--bind-address=127.0.0.1", f"--port={self.port}",
                "--socket=mysql.sock", "--mysqlx=OFF", *self.options, *[str(e) for e in extra]]
        if show:
            shown = ["mysqld", opts[0].replace(str(self.base) + "/", ""), "--datadir=./data", "--bind-address=127.0.0.1",
                     "--port=$PORT", "--socket=mysql.sock", "--mysqlx=OFF", *self.options, *extra]
            print("$ " + " ".join(_arg(a) if a != "--port=$PORT" else a for a in shown) + " &")
            print()
        with open(self.base / "mysqld.out", "a") as out:
            self.proc = subprocess.Popen(args, stdout=out, stderr=subprocess.STDOUT, cwd=self.base, env=self.env)
        for _ in range(600):
            if self.proc.poll() is not None:
                break
            if self._ping():
                break
            time.sleep(0.1)
        if self.proc.poll() is not None:
            new = (log.read_text() if log.exists() else "")[len(before):]
            lines = [l for l in self.clean(LOG_PREFIX.sub("", new)).splitlines() if "[ERROR]" in l]
            if expect_fail:
                print("\n".join(lines))
                print(f"(mysqld ended with status {self.proc.returncode})")
                print()
                self.proc = None
                return lines
            print("\n".join(lines))
            sys.exit(f"mysqld ended with status {self.proc.returncode} while starting")
        if expect_fail:
            sys.exit("mysqld started, but the session expected it to fail")
        if not self._ping():
            sys.exit("mysqld did not answer within 60 seconds")
        return []

    def _ping(self):
        code, _ = self._call(["mysqladmin", f"--defaults-file={self.client_cnf}", "--connect-timeout=2", "ping"])
        return code == 0

    def up(self, extra=(), defaults_file=None, show=False):
        """Initialise, start (with start()'s options), and give root the run's password, quietly."""
        self.initialize()
        self.start(extra=extra, defaults_file=defaults_file, show=show)
        return self.login()

    def login(self):
        """Gives root the run's password (never printed) on a server started from a new data directory, and makes
        every later client of the session use it. If this server has a label, its random server_uuid is masked to a
        stable value so GTID sets read the same on every run. A server started with --super-read-only=ON (a replica)
        blocks even root's ALTER USER, so super_read_only is turned off for the one password statement and restored.
        The statement runs with sql_log_bin off, for two reasons: the binary log a lesson decodes then holds only the
        lesson's own statements, and the replica keeps its own root password (each server of a run has a different one,
        so a replicated ALTER USER would lock the session out of the replica)."""
        sro = self.query("SELECT @@global.super_read_only") == "1"
        if sro:
            self.query("SET GLOBAL super_read_only=OFF")
        self.silent("SET SESSION sql_log_bin = 0;\n"
                    f"ALTER USER 'root'@'localhost' IDENTIFIED BY '{self.password}'")
        self._write_client_cnf(self.password)
        if sro:
            self.query("SET GLOBAL super_read_only=ON")
        if self.label:
            self.uuid = self.query("SELECT @@server_uuid")
            self.mask(re.escape(self.uuid), f"<{self.label}-uuid>")
        return self

    def stop(self, quiet=False):
        """A clean shutdown (mysqladmin shutdown), then waits until the process has ended."""
        code, out = self._call(["mysqladmin", f"--defaults-file={self.client_cnf}", "shutdown"])
        if code != 0 and not quiet:
            sys.exit(f"mysqladmin shutdown failed: {out}")
        try:
            self.proc.wait(timeout=120)
        except subprocess.TimeoutExpired:
            self.proc.terminate()
            self.proc.wait(timeout=60)
        self.proc = None

    def restart(self, extra=(), defaults_file=None, show=False):
        self.stop()
        self.start(extra=extra, defaults_file=defaults_file, show=show)

    # ---- running things --------------------------------------------------------------------------------------------
    def _call(self, args, stdin=None, merge=True, clean=True):
        """Runs one of MySQL's programs in the run folder: (status, cleaned output). merge=False keeps only what it
        wrote to standard output (its warnings on standard error are dropped). clean=False returns the output exactly
        as the program wrote it, for a value the session feeds back into SQL (a GTID set, a file name): a masked value
        is text for the record only and MySQL would reject it."""
        args = [str(a) for a in args]
        done = subprocess.run([str(self.bin / args[0]), *args[1:]], stdout=subprocess.PIPE,
                              stderr=subprocess.STDOUT if merge else subprocess.DEVNULL,
                              text=True, cwd=self.base, env=self.env, input=stdin)
        return done.returncode, (self.clean(done.stdout) if clean else done.stdout)

    def output(self, *args, connect=False, status=0):
        """Runs one of MySQL's programs without printing anything and returns its standard output, for example the
        SQL that mysql_tzinfo_to_sql writes, which the session saves with write_file(..., show=False) and runs next."""
        args = [str(a) for a in args]
        run = [args[0], f"--defaults-file={self.client_cnf}", *args[1:]] if connect else args
        code, out = self._call(run, merge=False)
        if code != status:
            sys.exit(f"{args[0]} ended with status {code}, not {status}")
        return out

    def clean(self, text):
        """The substitutions of the docstring. A table of the client whose cells change is drawn again with the
        new column widths, so its borders still line up."""
        lines, out, i = text.split("\n"), [], 0
        while i < len(lines):
            if lines[i].startswith("+-"):
                j = i
                while j < len(lines) and lines[j][:1] in ("+", "|"):
                    j += 1
                block = lines[i:j]
                done = [self._clean(l) for l in block]
                out.extend(done if done == block else _redraw(block, self._clean))
                i = j
            else:
                out.append(self._clean(lines[i]))
                i += 1
        return "\n".join(out)

    def _clean(self, text):
        text = text.replace(str(self.bin) + "/", "").replace(str(self.basedir), "<basedir>")
        text = text.replace(str(self.base) + "/", "./").replace(str(self.base), ".")
        text = re.sub(r" \(\d+\.\d{2,3} sec\)", "", text)  # 8.4 prints 2 decimals, 9.7 and 26.7 print 3
        text = re.sub(r'"Timestamp": \d+', '"Timestamp": <when it was set>', text)
        text = re.sub(rf"\b{re.escape(HOST)}\b", "lab-host", text)
        text = re.sub(rf"\b{re.escape(OS_USER)}\b", "learner", text)
        text = re.sub(rf"(?<![\d.]){self.port}(?![\d.])", self.port_label or "<port>", text)
        for pattern, replacement in self.masks:
            text = pattern.sub(replacement, text)
        return text

    def log(self, pattern=None):
        """The error log without timestamps and thread numbers, optionally only the lines matching a pattern."""
        text = self.clean(LOG_PREFIX.sub("", (self.base / "mysqld.err").read_text()))
        if pattern:
            text = "\n".join(l for l in text.splitlines() if re.search(pattern, l))
        return text

    def show_log(self, pattern, note=None):
        """Prints the error-log lines that match a pattern, as grep would."""
        print(f"$ grep -E {_arg(pattern)} mysqld.err" + (f"   # {note}" if note else ""))
        print(self.log(pattern))
        print()

    def query(self, sql, db=None):
        """One value from one query, unprinted and UNMASKED (the session uses it to wait or to decide, and often
        feeds it back into SQL: a masked value would be a syntax error). Use gtid_executed() or clean() for a value
        that goes into the record."""
        args = ["mysql", f"--defaults-file={self.client_cnf}", "-N", "-B", *([db] if db else [])]
        code, out = self._call(args, stdin=sql, clean=False)
        if code != 0:
            sys.exit(f"the query failed: {self.clean(out)}")
        return out.strip()

    def silent(self, sql):
        """Runs a statement without printing it (used only for the run's password)."""
        self.query(sql)

    def mysql(self, script, errors=0, status=None, db=None, flags=("-t", "-vvv", "--skip-comments"), note=None, force=None):
        """Runs a client script from standard input and prints the command and its output. `errors` is how many
        ERROR lines the script must print; with errors > 0 the client runs on past an error (--force) and then ends
        with status 0, without --force it stops at the first error with status 1. The default flags print each
        statement (-vvv) as a table (-t) without the script's comments (--skip-comments)."""
        force = errors > 0 if force is None else force
        shown = ["mysql", *flags, *(["--force"] if force else []), *([db] if db else [])]
        args = ["mysql", f"--defaults-file={self.client_cnf}", *shown[1:]]
        code, out = self._call(args, stdin=(self.base / script).read_text())
        if note:
            print(f"-- {note}")
        print("$ " + " ".join(_arg(a) for a in shown) + f" < {script}")
        if out.strip():
            print(out.rstrip("\n"))
        print()
        found = sum(1 for line in out.splitlines() if line.startswith("ERROR "))
        expected_status = (1 if errors and not force else 0) if status is None else status
        if found != errors or code != expected_status:
            sys.exit(f"{script}: {found} errors and status {code}; the session expects {errors} and {expected_status}")
        return out

    def shell(self, *args, status=0, shown=None, connect=False):
        """Runs one of MySQL's programs and prints the command and its output. connect=True gives a client program
        (mysqladmin, mysqlshow, mysql) the run's client option file, which the printed command leaves out."""
        args = [str(a) for a in args]
        run = [args[0], f"--defaults-file={self.client_cnf}", *args[1:]] if connect else args
        code, out = self._call(run)
        _show(shown or args, out)
        if code != status:
            sys.exit(f"{args[0]} ended with status {code}, not {status}")
        return out

    def sql(self, statement, flags=("-t",), errors=0, db=None, note=None):
        """Runs one statement with mysql -e and prints the command and its output."""
        shown = ["mysql", *flags, *([db] if db else []), "-e", statement]
        code, out = self._call(["mysql", f"--defaults-file={self.client_cnf}", *shown[1:]])
        if note:
            print(f"-- {note}")
        _show(shown, out)
        found = sum(1 for line in out.splitlines() if line.startswith("ERROR "))
        if found != errors or (code != 0) != (errors > 0):
            sys.exit(f"{statement}: {found} errors and status {code}; the session expects {errors}")
        return out

    def write_file(self, path, text, show=True):
        """Writes a text file into the run folder (an option file, a script) and prints it, as cat would."""
        (self.base / path).write_text(textwrap.dedent(text).lstrip("\n"))
        if show:
            self.show_file(path)

    def show_file(self, path):
        print(f"$ cat {path}")
        print(self.clean((self.base / path).read_text()).rstrip("\n"))
        print()

    def listing(self, folder, prefix=""):
        """Prints the names in a folder of the run (sorted, folders with a slash), as `ls -p` would list them."""
        path = self.base / folder
        names = sorted(n + ("/" if (path / n).is_dir() else "") for n in os.listdir(path) if n.startswith(prefix))
        print(f"$ ls -p {folder}" + (f" | grep '^{prefix}'" if prefix else ""))
        print(textwrap.fill("  ".join(names), width=78, break_on_hyphens=False))
        print()
        return names


    # ---- replication (two or three servers on 127.0.0.1) -----------------------------------------------------------
    def gtid_executed(self, raw=False):
        """This server's @@global.gtid_executed as one line (no newlines). The value is masked for the record (the
        server_uuid reads as <label-uuid>); raw=True gives the real set, which is what MySQL accepts in a statement."""
        value = self.query("SELECT @@global.gtid_executed").replace("\n", "")
        return value if raw else self.clean(value)

    def track(self, other):
        """Teach this server's record to mask another server's port and server_uuid, so a statement that names the
        other server (CHANGE REPLICATION SOURCE TO, SHOW REPLICA STATUS) reads the same on every run."""
        if other.port:
            self.mask(rf"(?<![\d.]){other.port}(?![\d.])", other.port_label or "<port>")
        if other.uuid:
            self.mask(re.escape(other.uuid), f"<{other.label}-uuid>")
        self.mask(re.escape(str(other.base)), ".")

    def change_source(self, source, user="repl", password="replpass", auto_position=True, ssl=False,
                      verify_ca=False, channel=None, delay=None, extra=(), show=True, run=True):
        """Builds and (run=True) runs CHANGE REPLICATION SOURCE TO on this replica, pointing at `source`. With
        verify_ca the source's auto-generated ca.pem is copied into the replica's data directory and the replica
        verifies the server certificate's issuer chains to it (OpenSSL's VERIFY_CA mode).
        A relative SOURCE_SSL_CA is resolved relative to the replica's data directory, so the file is copied there,
        not into the run folder.
        VERIFY_IDENTITY (SOURCE_SSL_VERIFY_SERVER_CERT=1, which also checks the server certificate's SAN against the
        host) is not wired up here: MySQL's --initialize-insecure auto-generates self-signed certificates whose
        subject is "MySQL_Server_<version>_Auto_Generated_Server_Certificate" and whose SAN does not include
        127.0.0.1, so VERIFY_IDENTITY would fail with "certificate verify failed". A production setup issues server
        certificates whose SAN includes the host and then enables SOURCE_SSL_VERIFY_SERVER_CERT=1."""
        self.track(source)
        opts = ["SOURCE_HOST='127.0.0.1'", f"SOURCE_PORT={source.port}", f"SOURCE_USER='{user}'",
                f"SOURCE_PASSWORD='{password}'"]
        if auto_position:
            opts.append("SOURCE_AUTO_POSITION=1")
        if ssl:
            opts.append("SOURCE_SSL=1")
        if verify_ca:
            shutil.copy(source.base / "data" / "ca.pem", self.base / "data" / "source-ca.pem")
            opts.append("SOURCE_SSL_CA='source-ca.pem'")
        if delay is not None:
            opts.append(f"SOURCE_DELAY={delay}")
        opts += [str(e) for e in extra]
        stmt = "CHANGE REPLICATION SOURCE TO\n  " + ",\n  ".join(opts)
        if channel:
            stmt += f"\n  FOR CHANNEL '{channel}'"
        stmt += ";"
        self.write_file("_change_source.sql", stmt + "\n", show=False)
        if run:
            if show:
                self.mysql("_change_source.sql")
            else:
                self.query(stmt)
        return stmt

    def start_replica(self, user=None, password=None, channel=None, show=True):
        """START REPLICA, optionally with USER/PASSWORD given at start time (not stored in the metadata repository)."""
        parts = ["START REPLICA"]
        if user:
            parts.append(f"USER='{user}' PASSWORD='{password}'")
        if channel:
            parts.append(f"FOR CHANNEL '{channel}'")
        stmt = " ".join(parts) + ";"
        if show:
            self.write_file("_start_replica.sql", stmt + "\n", show=False)
            self.mysql("_start_replica.sql")
        else:
            self.query(stmt)

    def stop_replica(self, channel=None, show=False):
        stmt = "STOP REPLICA" + (f" FOR CHANNEL '{channel}'" if channel else "")
        if show:
            self.sql(stmt)
        else:
            self.query(stmt)

    def wait_replica_running(self, channel=None, timeout=30):
        """Waits until both replication threads report ON in performance_schema (deterministic, no fixed sleep)."""
        where = f" WHERE CHANNEL_NAME='{channel}'" if channel else ""
        for _ in range(timeout * 10):
            io = self.query(f"SELECT SERVICE_STATE FROM performance_schema.replication_connection_status{where}")
            applier = self.query(f"SELECT SERVICE_STATE FROM performance_schema.replication_applier_status{where}")
            if io == "ON" and applier == "ON":
                return
            time.sleep(0.1)
        print(self.sql("SHOW REPLICA STATUS", flags=("-E",)))
        sys.exit("the replica did not reach the running state in time")

    def wait_caught_up(self, source, timeout=30):
        """Blocks until this replica has executed every transaction the source had committed when called."""
        gset = source.gtid_executed(raw=True)
        if not gset:
            return
        result = self.query(f"SELECT WAIT_FOR_EXECUTED_GTID_SET('{gset}', {timeout})")
        if result != "0":
            sys.exit(f"the replica did not catch up (WAIT_FOR_EXECUTED_GTID_SET returned {result})")

    def replica_status(self, fields, channel=None, note=None, label="SHOW REPLICA STATUS"):
        """Prints only the named SHOW REPLICA STATUS fields, in the order given, vertically (phone-friendly)."""
        stmt = "SHOW REPLICA STATUS" + (f" FOR CHANNEL '{channel}'" if channel else "")
        code, out = self._call(["mysql", f"--defaults-file={self.client_cnf}", "-E", "-e", stmt])
        if code != 0:
            sys.exit(f"SHOW REPLICA STATUS failed: {out}")
        values = {}
        for line in out.splitlines():
            m = re.match(r"\s*([A-Za-z_]+):\s?(.*)$", line)
            if m:
                values[m.group(1)] = m.group(2)
        if note:
            print(f"-- {note}")
        print(f"mysql> {label}\\G")
        for field in fields:
            if field in values:
                print(f"  {field}: {values[field]}")
        print()
        return values


def _redraw(block, clean):
    """Draws a client table again after its cells were cleaned. The columns come from the first border line; the row
    after it is the header (left-aligned); a column whose data cells were padded on the left (numbers) stays
    right-aligned (an empty cell says nothing about its column)."""
    cuts = [k for k, ch in enumerate(block[0]) if ch == "+"]
    rows = [None if line.startswith("+") else [line[x + 1:y] for x, y in zip(cuts, cuts[1:])] for line in block]
    header = next(k for k, r in enumerate(rows) if r is not None)
    ncol = len(cuts) - 1
    right = [any(r is not None and k != header and r[c].strip() and r[c].startswith("  ") for k, r in enumerate(rows))
             for c in range(ncol)]
    cells = [None if r is None else [clean(x.strip()) for x in r] for r in rows]
    widths = [max(len(r[c]) for r in cells if r is not None) for c in range(ncol)]
    drawn = []
    for k, r in enumerate(cells):
        if r is None:
            drawn.append("+" + "+".join("-" * (w + 2) for w in widths) + "+")
        else:
            drawn.append("|" + "|".join(" " + (t.rjust(w) if right[c] and k != header else t.ljust(w)) + " "
                                        for c, (t, w) in enumerate(zip(r, widths))) + "|")
    return drawn


def _arg(a):
    """A command-line argument as a reader would type it: quotes only where the shell needs them."""
    a = str(a)
    if re.fullmatch(r"[\w@%+=:,./-]+", a):
        return a
    if not re.search(r"[\"$`\\!]", a):
        return f'"{a}"'
    return shlex.quote(a)


def _show(args, out):
    print("$ " + " ".join(_arg(a) for a in args))
    if out.strip():
        print(out.rstrip("\n"))
    print()
Interactive · step by step Where MySQL stores the two kinds of privilege Compare the static store (columns of mysql.user) with the dynamic store (rows of mysql.global_grants).
Where MySQL stores the two kinds of privilege, step 4 of 4. Showing SHOW PRIVILEGES, 31 static, 40 dynamic, mysql.user, mysql.global_grants. In focus: 40 dynamic, mysql.global_grants.Select_priv Y/NBACKUP_ADMIN rowregistered onesSHOW PRIVILEGESlists both kinds31 staticmysql.user columns40 dynamicglobal_grants rowsmysql.usera row per accountmysql.global_grantsUSER HOST PRIVno schema or table column

The drawing shows the whole scene; its 4 steps are listed below.

Every step as text
  1. Two stores, one SHOW. SHOW PRIVILEGES lists every static privilege and every dynamic one registered so far, each with the Context it applies to, such as Databases, Tables or Server Admin. Where a grant of each kind is kept differs.
  2. Static lives in columns. A static privilege granted globally or on a schema is a Y or N column of mysql.user or mysql.db; its table and column grants are sets in mysql.tables_priv and mysql.columns_priv. The recorded run counts 31 such columns at the global level.
  3. Dynamic lives in rows. A dynamic privilege is a row of mysql.global_grants, with USER, HOST and PRIV columns. The recorded run counts 40 rows for root@localhost on 8.4.11 (BACKUP_ADMIN, SYSTEM_USER, FLUSH_TABLES among them).
  4. Dynamic grants only at ON *.*. GRANT BACKUP_ADMIN ON shop.* returns ERROR 3619, because mysql.global_grants has no schema column. The same GRANT ON . is accepted and recorded as one row.

Two differences follow from the shape. A dynamic privilege only grants at the global level, so GRANT BACKUP_ADMIN ON shop.* TO 'backup_bot'@'localhost' returns ERROR 3619 and GRANT BACKUP_ADMIN ON *.* ... is accepted. A dynamic privilege that nothing has registered cannot be granted at all: GRANT FIREWALL_ADMIN ON *.* fails as a plain syntax error (ERROR 1149), because the firewall that would register it ships only in the Enterprise build. The deprecated SUPER still works and leaves a warning on SHOW WARNINGS: The SUPER privilege identifier is deprecated.

Checkpoint

Check: Static versus dynamic privileges

  1. Question 1 of 2 Where does MySQL 8.4 keep the two kinds of privilege?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Static ones in _priv columns of mysql.user, dynamic ones as rows of mysql.global_grants

    The recorded run counts 31 static global privileges as _priv columns of mysql.user and 40 dynamic ones as rows of mysql.global_grants for root@localhost on MySQL 8.4.11.

  2. Question 2 of 2 Why does GRANT BACKUP_ADMIN ON shop.* return ERROR 3619?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A dynamic privilege only grants at the global level, ON *.*

    BACKUP_ADMIN is a dynamic privilege. The server refuses ON shop.* with ERROR 3619, "Illegal privilege level specified for BACKUP_ADMIN"; the same GRANT ON *.* is accepted.

Five accounts for five jobs, with only the grants of their work

The second recorded run gives five accounts five different jobs on one database. checkout reads and writes the rows of shop; migrator adds those four grants plus CREATE, ALTER, DROP, INDEX and REFERENCES for schema changes; reporting holds SELECT only; backup reads every schema (SELECT, SHOW VIEW, EVENT, TRIGGER on *.*) and the dynamic FLUSH_TABLES, which mysqldump --single-transaction needs on a server with GTIDs on; the replication connection holds exactly one grant, scoped to the subnet replicas log in from and refusing a plain-text login with A per-account rule that refuses any login that does not use TLS, set with REQUIRE SSL on CREATE USER or ALTER USER..

Five accounts with five jobs on a MySQL 8.4.11 server with GTIDs on

accounts_session.py

"""Five accounts with five jobs on a throwaway MySQL 8.4.11 server.

The server runs with GTIDs on, as a replication source does. accounts.sql gives each account the privileges of its
job; then each account tries something that belongs to another job. The backup account runs the dump it was set up
for, and the same dump once more after FLUSH_TABLES is taken away. Every password is made up for the run and kept in
its own client option file (<name>.cnf), which is never printed.
"""
import os
import secrets

os.chdir(os.path.dirname(__file__) or ".")

from mysql_session import Server


def login(my, name, host="localhost"):
    """Creates the account <name>@<host> with a random password, and <name>.cnf to connect as it."""
    password = secrets.token_urlsafe(18)
    my.silent(f"CREATE USER '{name}'@'{host}' IDENTIFIED BY '{password}'")
    cnf = my.base / f"{name}.cnf"
    cnf.write_text(f"[client]\nuser={name}\npassword={password}\nhost=127.0.0.1\nport={my.port}\nprotocol=TCP\n")
    cnf.chmod(0o600)


def as_user(my, name, statement, fails=False):
    """Runs one statement as that account, and prints the command and what it printed."""
    my.shell("mysql", f"--defaults-file={name}.cnf", "-t", "-e", statement, status=1 if fails else 0)


with Server(options=["--gtid-mode=ON", "--enforce-gtid-consistency=ON"], label="lab") as my:
    my.up()
    for name in ("checkout", "migrator", "reporting", "backup"):
        login(my, name)
    login(my, "repl", host="10.0.0.%")
    my.mysql("accounts.sql", flags=("-N",))

    print("# Each account stays inside its job.")
    as_user(my, "migrator", "ALTER TABLE shop.orders ADD COLUMN note VARCHAR(80)")
    as_user(my, "checkout", "ALTER TABLE shop.orders ADD COLUMN note2 VARCHAR(80)", fails=True)
    as_user(my, "reporting", "UPDATE shop.orders SET amount = 0 WHERE id = 1", fails=True)
    as_user(my, "backup", "DELETE FROM shop.orders", fails=True)

    print("# The backup account's dump works, and stops without FLUSH_TABLES.")
    dump = ["mysqldump", "--defaults-file=backup.cnf", "--single-transaction", "--no-tablespaces",
            "--compact", "--no-create-info", "shop"]
    my.shell(*dump)
    my.sql("REVOKE FLUSH_TABLES ON *.* FROM 'backup'@'localhost'")
    my.shell(*dump, status=2)

Output

$ mysql -N < accounts.sql
GRANT USAGE ON *.* TO `checkout`@`localhost`
GRANT SELECT, INSERT, UPDATE, DELETE ON `shop`.* TO `checkout`@`localhost`
GRANT USAGE ON *.* TO `migrator`@`localhost`
GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, REFERENCES, INDEX, ALTER ON `shop`.* TO `migrator`@`localhost`
GRANT USAGE ON *.* TO `reporting`@`localhost`
GRANT SELECT ON `shop`.* TO `reporting`@`localhost`
GRANT SELECT, SHOW VIEW, EVENT, TRIGGER ON *.* TO `backup`@`localhost`
GRANT FLUSH_TABLES ON *.* TO `backup`@`localhost`
GRANT REPLICATION SLAVE ON *.* TO `repl`@`10.0.0.%`

# Each account stays inside its job.
$ mysql --defaults-file=migrator.cnf -t -e "ALTER TABLE shop.orders ADD COLUMN note VARCHAR(80)"

$ mysql --defaults-file=checkout.cnf -t -e "ALTER TABLE shop.orders ADD COLUMN note2 VARCHAR(80)"
ERROR 1142 (42000) at line 1: ALTER command denied to user 'checkout'@'localhost' for table 'orders'

$ mysql --defaults-file=reporting.cnf -t -e "UPDATE shop.orders SET amount = 0 WHERE id = 1"
ERROR 1142 (42000) at line 1: UPDATE command denied to user 'reporting'@'localhost' for table 'orders'

$ mysql --defaults-file=backup.cnf -t -e "DELETE FROM shop.orders"
ERROR 1142 (42000) at line 1: DELETE command denied to user 'backup'@'localhost' for table 'orders'

# The backup account's dump works, and stops without FLUSH_TABLES.
$ mysqldump --defaults-file=backup.cnf --single-transaction --no-tablespaces --compact --no-create-info shop
Warning: A partial dump from a server that has GTIDs will by default include the GTIDs of all transactions, even those that changed suppressed parts of the database. If you don't want to restore GTIDs, pass --set-gtid-purged=OFF. To make a complete dump, pass --all-databases --triggers --routines --events. 
SET @MYSQLDUMP_TEMP_LOG_BIN = @@SESSION.SQL_LOG_BIN;
SET @@SESSION.SQL_LOG_BIN= 0;
SET @@GLOBAL.GTID_PURGED=/*!80000 '+'*/ '<lab-uuid>:1-16';
INSERT INTO `orders` VALUES (1,'asha',499.00,NULL),(2,'ravi',1250.50,NULL);
SET @@SESSION.SQL_LOG_BIN = @MYSQLDUMP_TEMP_LOG_BIN;

$ mysql -t -e "REVOKE FLUSH_TABLES ON *.* FROM 'backup'@'localhost'"

$ mysqldump --defaults-file=backup.cnf --single-transaction --no-tablespaces --compact --no-create-info shop
mysqldump: Couldn't execute 'FLUSH /*!40101 LOCAL */ TABLES': Access denied; you need (at least one of) the RELOAD or FLUSH_TABLES privilege(s) for this operation (1227)

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 accounts_session.py

accounts.sql

-- Run as root. The five accounts already exist, each with a password of its own; this script gives each one its job.
CREATE DATABASE shop;
CREATE TABLE shop.orders (
  id INT AUTO_INCREMENT PRIMARY KEY,
  customer VARCHAR(40) NOT NULL,
  amount DECIMAL(10,2) NOT NULL
);
INSERT INTO shop.orders (customer, amount) VALUES ('asha', 499.00), ('ravi', 1250.50);

-- 1. The application: the rows of its own schema, and nothing else.
GRANT SELECT, INSERT, UPDATE, DELETE ON shop.* TO 'checkout'@'localhost';

-- 2. The migrator: the schema's structure, plus the rows its backfills change.
GRANT CREATE, ALTER, DROP, INDEX, REFERENCES, SELECT, INSERT, UPDATE, DELETE ON shop.* TO 'migrator'@'localhost';

-- 3. Reporting: read only.
GRANT SELECT ON shop.* TO 'reporting'@'localhost';

-- 4. Logical backups with mysqldump --single-transaction --no-tablespaces --routines --events:
--    read everything, and FLUSH_TABLES (narrower than RELOAD) for the GTID position.
GRANT SELECT, SHOW VIEW, TRIGGER, EVENT ON *.* TO 'backup'@'localhost';
GRANT FLUSH_TABLES ON *.* TO 'backup'@'localhost';

-- 5. Replication: one privilege, from the replicas' subnet, over TLS only.
-- learn-lint-allow inclusive: MySQL 8.4 keeps this SQL name for the replication privilege
GRANT REPLICATION SLAVE ON *.* TO 'repl'@'10.0.0.%';
ALTER USER 'repl'@'10.0.0.%' REQUIRE SSL;

SHOW GRANTS FOR 'checkout'@'localhost';
SHOW GRANTS FOR 'migrator'@'localhost';
SHOW GRANTS FOR 'reporting'@'localhost';
SHOW GRANTS FOR 'backup'@'localhost';
SHOW GRANTS FOR 'repl'@'10.0.0.%';

mysql_session.py

"""Throwaway MySQL 8.4.11 servers for the examples of this lesson.

Every session creates a server of its own: `mysqld --initialize-insecure` makes a new data directory inside a new
folder in the examples folder, the server starts on it, the session runs `mysql` client scripts and MySQL's programs
against it and prints what they printed, and then the server is stopped and the folder deleted, also when something
fails. The server is the pinned MySQL 8.4.11 from the mise installations next to the Python that runs this file;
Server(basedir=...) runs the programs of another unpacked MySQL tarball instead, and tarball(name) finds one.

Safety: the server listens only on 127.0.0.1, an address no other computer can reach, on a port the operating system
reports as unused; the X Protocol listener is off (--mysqlx=OFF); its Unix socket, temporary files, error log and
import/export folder all stay inside the run folder. Right after the first start the session gives root a password made
up for the run (never printed) and connects only with a client option file that holds it, so no password ever appears
on a command line. Every program starts with --no-defaults or --defaults-file, so no option file of the computer
(/etc/my.cnf, ~/.my.cnf) can change a result, and HOME points at the run folder, so ~/.mylogin.cnf is not read either.
A printed mysqld command shows the options that matter to the lesson; the session also passes, on the command line,
the folders it keeps inside the run folder (--basedir, --tmpdir, --log-error, --pid-file, --secure-file-priv) and
the port, which is why performance_schema.variables_info reports those variables as COMMAND_LINE.
The sessions read only this folder and the pinned MySQL installations, and they connect to nothing but 127.0.0.1.

The output is exactly what the programs printed, with these substitutions so that a record never shows the computer it
ran on and is the same on every run: the run folder is shown as ".", the folder of MySQL's programs is left out of
their paths and MySQL's own folder is shown as <basedir>, the computer's name is shown as "lab-host" and the operating-system user's name as "learner", the time a
statement took (the client's "(0.01 sec)") is left out, the time stamp SET PERSIST writes into mysqld-auto.cnf is
shown as <when it was set>, error-log lines lose their timestamp and thread number, and a session can name more values
that change between runs with my.mask(pattern, replacement), which then prints in their place. A client table whose cells
change is drawn again so that its borders still line up.
The sessions never print the password, and the run's port prints as <port>. A session stops with status 1 when a command that should have worked
fails, or when a script reports a different number of errors than the session expects, so a run that went wrong is
never recorded as if it had worked.
"""

import os
import pwd
import re
import secrets
import shlex
import shutil
import socket
import subprocess
import sys
import tempfile
import textwrap
import time
from pathlib import Path

MYSQL_VERSION = "8.4.11"
# .../installs/python/3.14.8/bin/python3 -> .../installs
INSTALLS = Path(sys.executable).resolve().parents[3]
PINNED = INSTALLS / "mysql" / MYSQL_VERSION
OS_USER = pwd.getpwuid(os.geteuid()).pw_name
HOST = socket.gethostname().split(".")[0]
# 2026-10-17T16:50:01.123456Z 0 [ERROR] ... -> [ERROR] ...
LOG_PREFIX = re.compile(r"^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d+Z \d+ ", re.M)


def tarball(name):
    """The folder of an unpacked official tarball, such as mysql-9.7.2, for Server(basedir=...). It is the folder
    <name> in a folder called toolchains, looked for in every folder above the one the session runs in and above
    this file, directly or inside one of the folders of a tmp folder there. The search stops below the home folder."""
    home = Path(pwd.getpwuid(os.geteuid()).pw_dir).resolve()
    seen = set()
    for start in (Path.cwd().resolve(), Path(__file__).resolve().parent):
        for parent in (start, *start.parents):
            if parent in seen or parent == home or parent == Path(parent.anchor):
                break
            seen.add(parent)
            candidates = [parent / "toolchains" / name]
            if (parent / "tmp").is_dir():
                candidates += sorted((parent / "tmp").glob(f"*/toolchains/{name}"))
            for found in candidates:
                if (found / "bin").is_dir():
                    return found
    sys.exit(f"no toolchains folder above this one holds {name}")


def free_port():
    """A TCP port on 127.0.0.1 that nothing listens on now."""
    with socket.socket() as probe:
        probe.bind(("127.0.0.1", 0))
        return probe.getsockname()[1]


class Server:
    """One throwaway server: initialise, start; stopped and deleted when the with block ends.

    options: extra mysqld options for every start (for example "--sql-mode=" or "--character-set-server=latin1").
    basedir: the folder of an unpacked MySQL tarball to run instead of the pinned 8.4.11.
    masks: (pattern, replacement) pairs, as for mask().
    """

    def __init__(self, options=(), redo=None, basedir=None, masks=(), label=None, port_label=None):
        self.options = [str(o) for o in options]
        self.redo = redo
        self.basedir = Path(basedir).resolve() if basedir else PINNED
        self.bin = self.basedir / "bin"
        self.label = label                       # a stable name for this server in the record ("source", "replica")
        self.port_label = port_label             # the text that shows in place of this server's port
        self.uuid = None
        self.masks = []
        for pattern, replacement in masks:
            self.mask(pattern, replacement)
        self.proc = None

    def mask(self, pattern, replacement):
        """From now on every printed line shows text matching the regular expression as the replacement: for a value
        that changes from run to run (an uptime, a time stamp), so that the recorded output stays the same."""
        self.masks.append((re.compile(pattern), replacement))

    # ---- the folder ------------------------------------------------------------------------------------------------
    def __enter__(self):
        if not (self.bin / "mysqld").exists():
            sys.exit(f"no MySQL server program in {self.basedir}" if self.basedir != PINNED
                     else f"MySQL {MYSQL_VERSION} was not found next to this Python")
        self.base = Path(tempfile.mkdtemp(prefix="my", dir=".")).resolve()
        for sub in ("tmp", "files"):
            (self.base / sub).mkdir(mode=0o700)
        self.port = free_port()
        self.password = secrets.token_urlsafe(24)
        self.client_cnf = self.base / "client.cnf"
        self._write_client_cnf(password=None)
        self.env = {key: value for key, value in os.environ.items()
                    if not key.startswith("MYSQL") and key not in ("TZ", "HOME", "LANG", "LC_ALL")}
        self.env.update({"HOME": str(self.base), "TZ": "UTC", "LANG": "en_US.UTF-8",
                         "MYSQL_TEST_LOGIN_FILE": str(self.base / "no-login-paths.cnf")})
        for pattern in ("*.sql", "*.cnf", "*.csv", "*.txt"):
            for f in Path(".").glob(pattern):
                shutil.copy(f, self.base / f.name)
        return self

    def __exit__(self, *exc):
        if self.proc and self.proc.poll() is None:
            self.stop(quiet=True)
        shutil.rmtree(self.base, ignore_errors=True)

    def _write_client_cnf(self, password):
        lines = ["[client]", "user=root", "host=127.0.0.1", f"port={self.port}", "protocol=TCP"]
        if password:
            lines.append(f"password={password}")
        self.client_cnf.write_text("\n".join(lines) + "\n")
        self.client_cnf.chmod(0o600)

    def server_options(self):
        b = self.base
        return ["--no-defaults", f"--basedir={self.basedir}", f"--datadir={b / 'data'}", f"--tmpdir={b / 'tmp'}",
                f"--log-error={b / 'mysqld.err'}", f"--pid-file={b / 'mysqld.pid'}",
                f"--secure-file-priv={b / 'files'}",
                *([f"--innodb-redo-log-capacity={self.redo}"] if self.redo else [])]

    # ---- the server ------------------------------------------------------------------------------------------------
    def initialize(self, show=False):
        """mysqld --initialize-insecure: a new data directory whose root account has no password yet."""
        args = ["mysqld", *self.server_options(), "--initialize-insecure"]
        code, out = self._call(args)
        if show:
            _show(["mysqld", "--no-defaults", "--initialize-insecure", "--datadir=./data"], out)
        if code != 0:
            print(self.log())
            sys.exit(f"mysqld --initialize-insecure ended with status {code}")

    def start(self, extra=(), defaults_file=None, expect_fail=False, show=False):
        """Starts mysqld on 127.0.0.1 and waits until it answers. With expect_fail, prints the error-log lines of a
        start that must fail, and returns them."""
        log = self.base / "mysqld.err"
        before = log.read_text() if log.exists() else ""
        opts = self.server_options()
        if defaults_file:
            opts[0] = f"--defaults-file={self.base / defaults_file}"
        args = [str(self.bin / "mysqld"), *opts, "--bind-address=127.0.0.1", f"--port={self.port}",
                "--socket=mysql.sock", "--mysqlx=OFF", *self.options, *[str(e) for e in extra]]
        if show:
            shown = ["mysqld", opts[0].replace(str(self.base) + "/", ""), "--datadir=./data", "--bind-address=127.0.0.1",
                     "--port=$PORT", "--socket=mysql.sock", "--mysqlx=OFF", *self.options, *extra]
            print("$ " + " ".join(_arg(a) if a != "--port=$PORT" else a for a in shown) + " &")
            print()
        with open(self.base / "mysqld.out", "a") as out:
            self.proc = subprocess.Popen(args, stdout=out, stderr=subprocess.STDOUT, cwd=self.base, env=self.env)
        for _ in range(600):
            if self.proc.poll() is not None:
                break
            if self._ping():
                break
            time.sleep(0.1)
        if self.proc.poll() is not None:
            new = (log.read_text() if log.exists() else "")[len(before):]
            lines = [l for l in self.clean(LOG_PREFIX.sub("", new)).splitlines() if "[ERROR]" in l]
            if expect_fail:
                print("\n".join(lines))
                print(f"(mysqld ended with status {self.proc.returncode})")
                print()
                self.proc = None
                return lines
            print("\n".join(lines))
            sys.exit(f"mysqld ended with status {self.proc.returncode} while starting")
        if expect_fail:
            sys.exit("mysqld started, but the session expected it to fail")
        if not self._ping():
            sys.exit("mysqld did not answer within 60 seconds")
        return []

    def _ping(self):
        code, _ = self._call(["mysqladmin", f"--defaults-file={self.client_cnf}", "--connect-timeout=2", "ping"])
        return code == 0

    def up(self, extra=(), defaults_file=None, show=False):
        """Initialise, start (with start()'s options), and give root the run's password, quietly."""
        self.initialize()
        self.start(extra=extra, defaults_file=defaults_file, show=show)
        return self.login()

    def login(self):
        """Gives root the run's password (never printed) on a server started from a new data directory, and makes
        every later client of the session use it. If this server has a label, its random server_uuid is masked to a
        stable value so GTID sets read the same on every run. A server started with --super-read-only=ON (a replica)
        blocks even root's ALTER USER, so super_read_only is turned off for the one password statement and restored.
        The statement runs with sql_log_bin off, for two reasons: the binary log a lesson decodes then holds only the
        lesson's own statements, and the replica keeps its own root password (each server of a run has a different one,
        so a replicated ALTER USER would lock the session out of the replica)."""
        sro = self.query("SELECT @@global.super_read_only") == "1"
        if sro:
            self.query("SET GLOBAL super_read_only=OFF")
        self.silent("SET SESSION sql_log_bin = 0;\n"
                    f"ALTER USER 'root'@'localhost' IDENTIFIED BY '{self.password}'")
        self._write_client_cnf(self.password)
        if sro:
            self.query("SET GLOBAL super_read_only=ON")
        if self.label:
            self.uuid = self.query("SELECT @@server_uuid")
            self.mask(re.escape(self.uuid), f"<{self.label}-uuid>")
        return self

    def stop(self, quiet=False):
        """A clean shutdown (mysqladmin shutdown), then waits until the process has ended."""
        code, out = self._call(["mysqladmin", f"--defaults-file={self.client_cnf}", "shutdown"])
        if code != 0 and not quiet:
            sys.exit(f"mysqladmin shutdown failed: {out}")
        try:
            self.proc.wait(timeout=120)
        except subprocess.TimeoutExpired:
            self.proc.terminate()
            self.proc.wait(timeout=60)
        self.proc = None

    def restart(self, extra=(), defaults_file=None, show=False):
        self.stop()
        self.start(extra=extra, defaults_file=defaults_file, show=show)

    # ---- running things --------------------------------------------------------------------------------------------
    def _call(self, args, stdin=None, merge=True, clean=True):
        """Runs one of MySQL's programs in the run folder: (status, cleaned output). merge=False keeps only what it
        wrote to standard output (its warnings on standard error are dropped). clean=False returns the output exactly
        as the program wrote it, for a value the session feeds back into SQL (a GTID set, a file name): a masked value
        is text for the record only and MySQL would reject it."""
        args = [str(a) for a in args]
        done = subprocess.run([str(self.bin / args[0]), *args[1:]], stdout=subprocess.PIPE,
                              stderr=subprocess.STDOUT if merge else subprocess.DEVNULL,
                              text=True, cwd=self.base, env=self.env, input=stdin)
        return done.returncode, (self.clean(done.stdout) if clean else done.stdout)

    def output(self, *args, connect=False, status=0):
        """Runs one of MySQL's programs without printing anything and returns its standard output, for example the
        SQL that mysql_tzinfo_to_sql writes, which the session saves with write_file(..., show=False) and runs next."""
        args = [str(a) for a in args]
        run = [args[0], f"--defaults-file={self.client_cnf}", *args[1:]] if connect else args
        code, out = self._call(run, merge=False)
        if code != status:
            sys.exit(f"{args[0]} ended with status {code}, not {status}")
        return out

    def clean(self, text):
        """The substitutions of the docstring. A table of the client whose cells change is drawn again with the
        new column widths, so its borders still line up."""
        lines, out, i = text.split("\n"), [], 0
        while i < len(lines):
            if lines[i].startswith("+-"):
                j = i
                while j < len(lines) and lines[j][:1] in ("+", "|"):
                    j += 1
                block = lines[i:j]
                done = [self._clean(l) for l in block]
                out.extend(done if done == block else _redraw(block, self._clean))
                i = j
            else:
                out.append(self._clean(lines[i]))
                i += 1
        return "\n".join(out)

    def _clean(self, text):
        text = text.replace(str(self.bin) + "/", "").replace(str(self.basedir), "<basedir>")
        text = text.replace(str(self.base) + "/", "./").replace(str(self.base), ".")
        text = re.sub(r" \(\d+\.\d{2,3} sec\)", "", text)  # 8.4 prints 2 decimals, 9.7 and 26.7 print 3
        text = re.sub(r'"Timestamp": \d+', '"Timestamp": <when it was set>', text)
        text = re.sub(rf"\b{re.escape(HOST)}\b", "lab-host", text)
        text = re.sub(rf"\b{re.escape(OS_USER)}\b", "learner", text)
        text = re.sub(rf"(?<![\d.]){self.port}(?![\d.])", self.port_label or "<port>", text)
        for pattern, replacement in self.masks:
            text = pattern.sub(replacement, text)
        return text

    def log(self, pattern=None):
        """The error log without timestamps and thread numbers, optionally only the lines matching a pattern."""
        text = self.clean(LOG_PREFIX.sub("", (self.base / "mysqld.err").read_text()))
        if pattern:
            text = "\n".join(l for l in text.splitlines() if re.search(pattern, l))
        return text

    def show_log(self, pattern, note=None):
        """Prints the error-log lines that match a pattern, as grep would."""
        print(f"$ grep -E {_arg(pattern)} mysqld.err" + (f"   # {note}" if note else ""))
        print(self.log(pattern))
        print()

    def query(self, sql, db=None):
        """One value from one query, unprinted and UNMASKED (the session uses it to wait or to decide, and often
        feeds it back into SQL: a masked value would be a syntax error). Use gtid_executed() or clean() for a value
        that goes into the record."""
        args = ["mysql", f"--defaults-file={self.client_cnf}", "-N", "-B", *([db] if db else [])]
        code, out = self._call(args, stdin=sql, clean=False)
        if code != 0:
            sys.exit(f"the query failed: {self.clean(out)}")
        return out.strip()

    def silent(self, sql):
        """Runs a statement without printing it (used only for the run's password)."""
        self.query(sql)

    def mysql(self, script, errors=0, status=None, db=None, flags=("-t", "-vvv", "--skip-comments"), note=None, force=None):
        """Runs a client script from standard input and prints the command and its output. `errors` is how many
        ERROR lines the script must print; with errors > 0 the client runs on past an error (--force) and then ends
        with status 0, without --force it stops at the first error with status 1. The default flags print each
        statement (-vvv) as a table (-t) without the script's comments (--skip-comments)."""
        force = errors > 0 if force is None else force
        shown = ["mysql", *flags, *(["--force"] if force else []), *([db] if db else [])]
        args = ["mysql", f"--defaults-file={self.client_cnf}", *shown[1:]]
        code, out = self._call(args, stdin=(self.base / script).read_text())
        if note:
            print(f"-- {note}")
        print("$ " + " ".join(_arg(a) for a in shown) + f" < {script}")
        if out.strip():
            print(out.rstrip("\n"))
        print()
        found = sum(1 for line in out.splitlines() if line.startswith("ERROR "))
        expected_status = (1 if errors and not force else 0) if status is None else status
        if found != errors or code != expected_status:
            sys.exit(f"{script}: {found} errors and status {code}; the session expects {errors} and {expected_status}")
        return out

    def shell(self, *args, status=0, shown=None, connect=False):
        """Runs one of MySQL's programs and prints the command and its output. connect=True gives a client program
        (mysqladmin, mysqlshow, mysql) the run's client option file, which the printed command leaves out."""
        args = [str(a) for a in args]
        run = [args[0], f"--defaults-file={self.client_cnf}", *args[1:]] if connect else args
        code, out = self._call(run)
        _show(shown or args, out)
        if code != status:
            sys.exit(f"{args[0]} ended with status {code}, not {status}")
        return out

    def sql(self, statement, flags=("-t",), errors=0, db=None, note=None):
        """Runs one statement with mysql -e and prints the command and its output."""
        shown = ["mysql", *flags, *([db] if db else []), "-e", statement]
        code, out = self._call(["mysql", f"--defaults-file={self.client_cnf}", *shown[1:]])
        if note:
            print(f"-- {note}")
        _show(shown, out)
        found = sum(1 for line in out.splitlines() if line.startswith("ERROR "))
        if found != errors or (code != 0) != (errors > 0):
            sys.exit(f"{statement}: {found} errors and status {code}; the session expects {errors}")
        return out

    def write_file(self, path, text, show=True):
        """Writes a text file into the run folder (an option file, a script) and prints it, as cat would."""
        (self.base / path).write_text(textwrap.dedent(text).lstrip("\n"))
        if show:
            self.show_file(path)

    def show_file(self, path):
        print(f"$ cat {path}")
        print(self.clean((self.base / path).read_text()).rstrip("\n"))
        print()

    def listing(self, folder, prefix=""):
        """Prints the names in a folder of the run (sorted, folders with a slash), as `ls -p` would list them."""
        path = self.base / folder
        names = sorted(n + ("/" if (path / n).is_dir() else "") for n in os.listdir(path) if n.startswith(prefix))
        print(f"$ ls -p {folder}" + (f" | grep '^{prefix}'" if prefix else ""))
        print(textwrap.fill("  ".join(names), width=78, break_on_hyphens=False))
        print()
        return names


    # ---- replication (two or three servers on 127.0.0.1) -----------------------------------------------------------
    def gtid_executed(self, raw=False):
        """This server's @@global.gtid_executed as one line (no newlines). The value is masked for the record (the
        server_uuid reads as <label-uuid>); raw=True gives the real set, which is what MySQL accepts in a statement."""
        value = self.query("SELECT @@global.gtid_executed").replace("\n", "")
        return value if raw else self.clean(value)

    def track(self, other):
        """Teach this server's record to mask another server's port and server_uuid, so a statement that names the
        other server (CHANGE REPLICATION SOURCE TO, SHOW REPLICA STATUS) reads the same on every run."""
        if other.port:
            self.mask(rf"(?<![\d.]){other.port}(?![\d.])", other.port_label or "<port>")
        if other.uuid:
            self.mask(re.escape(other.uuid), f"<{other.label}-uuid>")
        self.mask(re.escape(str(other.base)), ".")

    def change_source(self, source, user="repl", password="replpass", auto_position=True, ssl=False,
                      verify_ca=False, channel=None, delay=None, extra=(), show=True, run=True):
        """Builds and (run=True) runs CHANGE REPLICATION SOURCE TO on this replica, pointing at `source`. With
        verify_ca the source's auto-generated ca.pem is copied into the replica's data directory and the replica
        verifies the server certificate's issuer chains to it (OpenSSL's VERIFY_CA mode).
        A relative SOURCE_SSL_CA is resolved relative to the replica's data directory, so the file is copied there,
        not into the run folder.
        VERIFY_IDENTITY (SOURCE_SSL_VERIFY_SERVER_CERT=1, which also checks the server certificate's SAN against the
        host) is not wired up here: MySQL's --initialize-insecure auto-generates self-signed certificates whose
        subject is "MySQL_Server_<version>_Auto_Generated_Server_Certificate" and whose SAN does not include
        127.0.0.1, so VERIFY_IDENTITY would fail with "certificate verify failed". A production setup issues server
        certificates whose SAN includes the host and then enables SOURCE_SSL_VERIFY_SERVER_CERT=1."""
        self.track(source)
        opts = ["SOURCE_HOST='127.0.0.1'", f"SOURCE_PORT={source.port}", f"SOURCE_USER='{user}'",
                f"SOURCE_PASSWORD='{password}'"]
        if auto_position:
            opts.append("SOURCE_AUTO_POSITION=1")
        if ssl:
            opts.append("SOURCE_SSL=1")
        if verify_ca:
            shutil.copy(source.base / "data" / "ca.pem", self.base / "data" / "source-ca.pem")
            opts.append("SOURCE_SSL_CA='source-ca.pem'")
        if delay is not None:
            opts.append(f"SOURCE_DELAY={delay}")
        opts += [str(e) for e in extra]
        stmt = "CHANGE REPLICATION SOURCE TO\n  " + ",\n  ".join(opts)
        if channel:
            stmt += f"\n  FOR CHANNEL '{channel}'"
        stmt += ";"
        self.write_file("_change_source.sql", stmt + "\n", show=False)
        if run:
            if show:
                self.mysql("_change_source.sql")
            else:
                self.query(stmt)
        return stmt

    def start_replica(self, user=None, password=None, channel=None, show=True):
        """START REPLICA, optionally with USER/PASSWORD given at start time (not stored in the metadata repository)."""
        parts = ["START REPLICA"]
        if user:
            parts.append(f"USER='{user}' PASSWORD='{password}'")
        if channel:
            parts.append(f"FOR CHANNEL '{channel}'")
        stmt = " ".join(parts) + ";"
        if show:
            self.write_file("_start_replica.sql", stmt + "\n", show=False)
            self.mysql("_start_replica.sql")
        else:
            self.query(stmt)

    def stop_replica(self, channel=None, show=False):
        stmt = "STOP REPLICA" + (f" FOR CHANNEL '{channel}'" if channel else "")
        if show:
            self.sql(stmt)
        else:
            self.query(stmt)

    def wait_replica_running(self, channel=None, timeout=30):
        """Waits until both replication threads report ON in performance_schema (deterministic, no fixed sleep)."""
        where = f" WHERE CHANNEL_NAME='{channel}'" if channel else ""
        for _ in range(timeout * 10):
            io = self.query(f"SELECT SERVICE_STATE FROM performance_schema.replication_connection_status{where}")
            applier = self.query(f"SELECT SERVICE_STATE FROM performance_schema.replication_applier_status{where}")
            if io == "ON" and applier == "ON":
                return
            time.sleep(0.1)
        print(self.sql("SHOW REPLICA STATUS", flags=("-E",)))
        sys.exit("the replica did not reach the running state in time")

    def wait_caught_up(self, source, timeout=30):
        """Blocks until this replica has executed every transaction the source had committed when called."""
        gset = source.gtid_executed(raw=True)
        if not gset:
            return
        result = self.query(f"SELECT WAIT_FOR_EXECUTED_GTID_SET('{gset}', {timeout})")
        if result != "0":
            sys.exit(f"the replica did not catch up (WAIT_FOR_EXECUTED_GTID_SET returned {result})")

    def replica_status(self, fields, channel=None, note=None, label="SHOW REPLICA STATUS"):
        """Prints only the named SHOW REPLICA STATUS fields, in the order given, vertically (phone-friendly)."""
        stmt = "SHOW REPLICA STATUS" + (f" FOR CHANNEL '{channel}'" if channel else "")
        code, out = self._call(["mysql", f"--defaults-file={self.client_cnf}", "-E", "-e", stmt])
        if code != 0:
            sys.exit(f"SHOW REPLICA STATUS failed: {out}")
        values = {}
        for line in out.splitlines():
            m = re.match(r"\s*([A-Za-z_]+):\s?(.*)$", line)
            if m:
                values[m.group(1)] = m.group(2)
        if note:
            print(f"-- {note}")
        print(f"mysql> {label}\\G")
        for field in fields:
            if field in values:
                print(f"  {field}: {values[field]}")
        print()
        return values


def _redraw(block, clean):
    """Draws a client table again after its cells were cleaned. The columns come from the first border line; the row
    after it is the header (left-aligned); a column whose data cells were padded on the left (numbers) stays
    right-aligned (an empty cell says nothing about its column)."""
    cuts = [k for k, ch in enumerate(block[0]) if ch == "+"]
    rows = [None if line.startswith("+") else [line[x + 1:y] for x, y in zip(cuts, cuts[1:])] for line in block]
    header = next(k for k, r in enumerate(rows) if r is not None)
    ncol = len(cuts) - 1
    right = [any(r is not None and k != header and r[c].strip() and r[c].startswith("  ") for k, r in enumerate(rows))
             for c in range(ncol)]
    cells = [None if r is None else [clean(x.strip()) for x in r] for r in rows]
    widths = [max(len(r[c]) for r in cells if r is not None) for c in range(ncol)]
    drawn = []
    for k, r in enumerate(cells):
        if r is None:
            drawn.append("+" + "+".join("-" * (w + 2) for w in widths) + "+")
        else:
            drawn.append("|" + "|".join(" " + (t.rjust(w) if right[c] and k != header else t.ljust(w)) + " "
                                        for c, (t, w) in enumerate(zip(r, widths))) + "|")
    return drawn


def _arg(a):
    """A command-line argument as a reader would type it: quotes only where the shell needs them."""
    a = str(a)
    if re.fullmatch(r"[\w@%+=:,./-]+", a):
        return a
    if not re.search(r"[\"$`\\!]", a):
        return f'"{a}"'
    return shlex.quote(a)


def _show(args, out):
    print("$ " + " ".join(_arg(a) for a in args))
    if out.strip():
        print(out.rstrip("\n"))
    print()

The record then logs in as each account to prove the fences. migrator adds a column; checkout tries the same ALTER and the server returns ERROR 1142, ALTER command denied; reporting is denied UPDATE, backup is denied DELETE. The backup account’s dump works; after REVOKE FLUSH_TABLES, mysqldump fails right at FLUSH /*!40101 LOCAL */ TABLES with ERROR 1227.

Key idea

Give each account only the rights of its one job, scope its host to the network it logs in from, and set REQUIRE SSL on any account that reaches the server over a shared network. That is what least privilege looks like on a running MySQL server.

The replication account’s one grant is the replication privilege, which GRANT and SHOW GRANTS still spell with its A SQL privilege MySQL 8.4 still identifies by its older name, inside a GRANT or SHOW GRANTS statement, even though the related statements have been renamed., although the statements around it moved to the REPLICA and SOURCE wording.

Checkpoint

Check: Five accounts for five jobs

  1. One question In accounts.sql, the backup account is given SELECT, SHOW VIEW, EVENT, TRIGGER ON *.* plus one dynamic privilege. The recorded run then runs REVOKE FLUSH_TABLES ON *.* FROM 'backup'@'localhost' and reruns mysqldump. What does the second dump print?

    Choose one answer.

    Show the answer: One question

    Answer: Access denied; you need (at least one of) the RELOAD or FLUSH_TABLES privilege(s) for this operation

    The 8.4 mysqldump page requires RELOAD or FLUSH_TABLES for --single-transaction on a server with gtid_mode=ON when the dump also writes the GTID set, as the first dump does (SET @@GLOBAL.GTID_PURGED). Without either, the dump ends at its FLUSH TABLES step with ERROR 1227 before it writes any row, which is why least privilege for backups still includes one dynamic privilege.

A role groups privileges; the account holds the role

roles.sql creates three roles, app_rw, app_ddl and app_read, and grants them SELECT, INSERT, UPDATE, DELETE on shop.*, CREATE, ALTER, DROP, INDEX, REFERENCES on shop.* and SELECT on shop.*. Each account then takes the roles its job needs: checkout holds app_rw, migrator holds app_ddl and app_rw, reporting and oncall hold app_read.

Roles and default roles for the application, the migrator and reporting

roles_session.py

"""Roles for the application, the migrator and reporting on a throwaway MySQL 8.4.11 server.

roles.sql creates three roles and grants them to four accounts. The session reads the grants with and without
SHOW GRANTS ... USING, logs in as reporting before and after it has a default role, turns its role on by hand with
SET ROLE, and logs in as oncall while activate_all_roles_on_login is ON. Every password is made up for the run and
kept in its own client option file (<name>.cnf), which is never printed.
"""
import os
import secrets

os.chdir(os.path.dirname(__file__) or ".")

from mysql_session import Server


def login(my, name):
    """Creates the account <name>@localhost with a random password, and <name>.cnf to connect as it."""
    password = secrets.token_urlsafe(18)
    my.silent(f"CREATE USER '{name}'@'localhost' IDENTIFIED BY '{password}'")
    cnf = my.base / f"{name}.cnf"
    cnf.write_text(f"[client]\nuser={name}\npassword={password}\nhost=127.0.0.1\nport={my.port}\nprotocol=TCP\n")
    cnf.chmod(0o600)


def as_user(my, name, statement, fails=False):
    """Logs in as that account, runs the statements, and prints the command and what it printed."""
    my.shell("mysql", f"--defaults-file={name}.cnf", "-t", "-e", statement, status=1 if fails else 0)


with Server() as my:
    my.up()
    for name in ("checkout", "migrator", "reporting", "oncall"):
        login(my, name)
    my.mysql("roles.sql", flags=("-t",))

    print("# 1. SHOW GRANTS names the roles; USING also lists what they grant.")
    my.sql("SHOW GRANTS FOR 'migrator'@'localhost'")
    my.sql("SHOW GRANTS FOR 'migrator'@'localhost' USING 'app_ddl', 'app_rw'")

    print("# 2. reporting holds app_read, but no role is active at login.")
    as_user(my, "reporting", "SELECT CURRENT_ROLE(); SELECT COUNT(*) FROM shop.orders", fails=True)

    print("# 3. SET ROLE turns it on for this session only.")
    as_user(my, "reporting", "SET ROLE 'app_read'; SELECT CURRENT_ROLE(); SELECT COUNT(*) FROM shop.orders")

    print("# 4. A default role is active from every login on, and grants nothing more than the role holds.")
    my.sql("SET DEFAULT ROLE 'app_read' TO 'reporting'@'localhost'")
    as_user(my, "reporting", "SELECT CURRENT_ROLE(); SELECT COUNT(*) FROM shop.orders; DELETE FROM shop.orders",
            fails=True)

    print("# 5. activate_all_roles_on_login turns on every granted role, for every account.")
    my.sql("SET GLOBAL activate_all_roles_on_login = ON")
    as_user(my, "oncall", "SELECT CURRENT_ROLE()")
    my.sql("SET GLOBAL activate_all_roles_on_login = OFF")

    print("# 6. A role is an account that nobody can log in as.")
    my.sql("SELECT User, Host, account_locked FROM mysql.user WHERE User LIKE 'app%' ORDER BY User")

Output

$ mysql -t < roles.sql

# 1. SHOW GRANTS names the roles; USING also lists what they grant.
$ mysql -t -e "SHOW GRANTS FOR 'migrator'@'localhost'"
+------------------------------------------------------------+
| Grants for migrator@localhost                              |
+------------------------------------------------------------+
| GRANT USAGE ON *.* TO `migrator`@`localhost`               |
| GRANT `app_ddl`@`%`,`app_rw`@`%` TO `migrator`@`localhost` |
+------------------------------------------------------------+

$ mysql -t -e "SHOW GRANTS FOR 'migrator'@'localhost' USING 'app_ddl', 'app_rw'"
+--------------------------------------------------------------------------------------------------------------------+
| Grants for migrator@localhost                                                                                      |
+--------------------------------------------------------------------------------------------------------------------+
| GRANT USAGE ON *.* TO `migrator`@`localhost`                                                                       |
| GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, REFERENCES, INDEX, ALTER ON `shop`.* TO `migrator`@`localhost` |
| GRANT `app_ddl`@`%`,`app_rw`@`%` TO `migrator`@`localhost`                                                         |
+--------------------------------------------------------------------------------------------------------------------+

# 2. reporting holds app_read, but no role is active at login.
$ mysql --defaults-file=reporting.cnf -t -e "SELECT CURRENT_ROLE(); SELECT COUNT(*) FROM shop.orders"
+----------------+
| CURRENT_ROLE() |
+----------------+
| NONE           |
+----------------+
ERROR 1142 (42000) at line 1: SELECT command denied to user 'reporting'@'localhost' for table 'orders'

# 3. SET ROLE turns it on for this session only.
$ mysql --defaults-file=reporting.cnf -t -e "SET ROLE 'app_read'; SELECT CURRENT_ROLE(); SELECT COUNT(*) FROM shop.orders"
+----------------+
| CURRENT_ROLE() |
+----------------+
| `app_read`@`%` |
+----------------+
+----------+
| COUNT(*) |
+----------+
|        2 |
+----------+

# 4. A default role is active from every login on, and grants nothing more than the role holds.
$ mysql -t -e "SET DEFAULT ROLE 'app_read' TO 'reporting'@'localhost'"

$ mysql --defaults-file=reporting.cnf -t -e "SELECT CURRENT_ROLE(); SELECT COUNT(*) FROM shop.orders; DELETE FROM shop.orders"
+----------------+
| CURRENT_ROLE() |
+----------------+
| `app_read`@`%` |
+----------------+
+----------+
| COUNT(*) |
+----------+
|        2 |
+----------+
ERROR 1142 (42000) at line 1: DELETE command denied to user 'reporting'@'localhost' for table 'orders'

# 5. activate_all_roles_on_login turns on every granted role, for every account.
$ mysql -t -e "SET GLOBAL activate_all_roles_on_login = ON"

$ mysql --defaults-file=oncall.cnf -t -e "SELECT CURRENT_ROLE()"
+----------------+
| CURRENT_ROLE() |
+----------------+
| `app_read`@`%` |
+----------------+

$ mysql -t -e "SET GLOBAL activate_all_roles_on_login = OFF"

# 6. A role is an account that nobody can log in as.
$ mysql -t -e "SELECT User, Host, account_locked FROM mysql.user WHERE User LIKE 'app%' ORDER BY User"
+----------+------+----------------+
| User     | Host | account_locked |
+----------+------+----------------+
| app_ddl  | %    | Y              |
| app_read | %    | Y              |
| app_rw   | %    | Y              |
+----------+------+----------------+

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 roles_session.py

roles.sql

-- Run as root. The accounts checkout, migrator, reporting and oncall already exist, each with a password of its own.
CREATE DATABASE shop;
CREATE TABLE shop.orders (
  id INT AUTO_INCREMENT PRIMARY KEY,
  customer VARCHAR(40) NOT NULL,
  amount DECIMAL(10,2) NOT NULL
);
INSERT INTO shop.orders (customer, amount) VALUES ('asha', 499.00), ('ravi', 1250.50);

-- One role per kind of job. A role is created locked and without a password.
CREATE ROLE 'app_rw', 'app_ddl', 'app_read';
GRANT SELECT, INSERT, UPDATE, DELETE ON shop.* TO 'app_rw';
GRANT CREATE, ALTER, DROP, INDEX, REFERENCES ON shop.* TO 'app_ddl';
GRANT SELECT ON shop.* TO 'app_read';

-- Accounts get roles, not privileges.
GRANT 'app_rw' TO 'checkout'@'localhost';
GRANT 'app_ddl', 'app_rw' TO 'migrator'@'localhost';
GRANT 'app_read' TO 'reporting'@'localhost', 'oncall'@'localhost';

-- checkout and migrator start every session with their roles active.
-- reporting and oncall get no default role, so their role stays inactive at login.
SET DEFAULT ROLE ALL TO 'checkout'@'localhost', 'migrator'@'localhost';

mysql_session.py

"""Throwaway MySQL 8.4.11 servers for the examples of this lesson.

Every session creates a server of its own: `mysqld --initialize-insecure` makes a new data directory inside a new
folder in the examples folder, the server starts on it, the session runs `mysql` client scripts and MySQL's programs
against it and prints what they printed, and then the server is stopped and the folder deleted, also when something
fails. The server is the pinned MySQL 8.4.11 from the mise installations next to the Python that runs this file;
Server(basedir=...) runs the programs of another unpacked MySQL tarball instead, and tarball(name) finds one.

Safety: the server listens only on 127.0.0.1, an address no other computer can reach, on a port the operating system
reports as unused; the X Protocol listener is off (--mysqlx=OFF); its Unix socket, temporary files, error log and
import/export folder all stay inside the run folder. Right after the first start the session gives root a password made
up for the run (never printed) and connects only with a client option file that holds it, so no password ever appears
on a command line. Every program starts with --no-defaults or --defaults-file, so no option file of the computer
(/etc/my.cnf, ~/.my.cnf) can change a result, and HOME points at the run folder, so ~/.mylogin.cnf is not read either.
A printed mysqld command shows the options that matter to the lesson; the session also passes, on the command line,
the folders it keeps inside the run folder (--basedir, --tmpdir, --log-error, --pid-file, --secure-file-priv) and
the port, which is why performance_schema.variables_info reports those variables as COMMAND_LINE.
The sessions read only this folder and the pinned MySQL installations, and they connect to nothing but 127.0.0.1.

The output is exactly what the programs printed, with these substitutions so that a record never shows the computer it
ran on and is the same on every run: the run folder is shown as ".", the folder of MySQL's programs is left out of
their paths and MySQL's own folder is shown as <basedir>, the computer's name is shown as "lab-host" and the operating-system user's name as "learner", the time a
statement took (the client's "(0.01 sec)") is left out, the time stamp SET PERSIST writes into mysqld-auto.cnf is
shown as <when it was set>, error-log lines lose their timestamp and thread number, and a session can name more values
that change between runs with my.mask(pattern, replacement), which then prints in their place. A client table whose cells
change is drawn again so that its borders still line up.
The sessions never print the password, and the run's port prints as <port>. A session stops with status 1 when a command that should have worked
fails, or when a script reports a different number of errors than the session expects, so a run that went wrong is
never recorded as if it had worked.
"""

import os
import pwd
import re
import secrets
import shlex
import shutil
import socket
import subprocess
import sys
import tempfile
import textwrap
import time
from pathlib import Path

MYSQL_VERSION = "8.4.11"
# .../installs/python/3.14.8/bin/python3 -> .../installs
INSTALLS = Path(sys.executable).resolve().parents[3]
PINNED = INSTALLS / "mysql" / MYSQL_VERSION
OS_USER = pwd.getpwuid(os.geteuid()).pw_name
HOST = socket.gethostname().split(".")[0]
# 2026-10-17T16:50:01.123456Z 0 [ERROR] ... -> [ERROR] ...
LOG_PREFIX = re.compile(r"^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d+Z \d+ ", re.M)


def tarball(name):
    """The folder of an unpacked official tarball, such as mysql-9.7.2, for Server(basedir=...). It is the folder
    <name> in a folder called toolchains, looked for in every folder above the one the session runs in and above
    this file, directly or inside one of the folders of a tmp folder there. The search stops below the home folder."""
    home = Path(pwd.getpwuid(os.geteuid()).pw_dir).resolve()
    seen = set()
    for start in (Path.cwd().resolve(), Path(__file__).resolve().parent):
        for parent in (start, *start.parents):
            if parent in seen or parent == home or parent == Path(parent.anchor):
                break
            seen.add(parent)
            candidates = [parent / "toolchains" / name]
            if (parent / "tmp").is_dir():
                candidates += sorted((parent / "tmp").glob(f"*/toolchains/{name}"))
            for found in candidates:
                if (found / "bin").is_dir():
                    return found
    sys.exit(f"no toolchains folder above this one holds {name}")


def free_port():
    """A TCP port on 127.0.0.1 that nothing listens on now."""
    with socket.socket() as probe:
        probe.bind(("127.0.0.1", 0))
        return probe.getsockname()[1]


class Server:
    """One throwaway server: initialise, start; stopped and deleted when the with block ends.

    options: extra mysqld options for every start (for example "--sql-mode=" or "--character-set-server=latin1").
    basedir: the folder of an unpacked MySQL tarball to run instead of the pinned 8.4.11.
    masks: (pattern, replacement) pairs, as for mask().
    """

    def __init__(self, options=(), redo=None, basedir=None, masks=(), label=None, port_label=None):
        self.options = [str(o) for o in options]
        self.redo = redo
        self.basedir = Path(basedir).resolve() if basedir else PINNED
        self.bin = self.basedir / "bin"
        self.label = label                       # a stable name for this server in the record ("source", "replica")
        self.port_label = port_label             # the text that shows in place of this server's port
        self.uuid = None
        self.masks = []
        for pattern, replacement in masks:
            self.mask(pattern, replacement)
        self.proc = None

    def mask(self, pattern, replacement):
        """From now on every printed line shows text matching the regular expression as the replacement: for a value
        that changes from run to run (an uptime, a time stamp), so that the recorded output stays the same."""
        self.masks.append((re.compile(pattern), replacement))

    # ---- the folder ------------------------------------------------------------------------------------------------
    def __enter__(self):
        if not (self.bin / "mysqld").exists():
            sys.exit(f"no MySQL server program in {self.basedir}" if self.basedir != PINNED
                     else f"MySQL {MYSQL_VERSION} was not found next to this Python")
        self.base = Path(tempfile.mkdtemp(prefix="my", dir=".")).resolve()
        for sub in ("tmp", "files"):
            (self.base / sub).mkdir(mode=0o700)
        self.port = free_port()
        self.password = secrets.token_urlsafe(24)
        self.client_cnf = self.base / "client.cnf"
        self._write_client_cnf(password=None)
        self.env = {key: value for key, value in os.environ.items()
                    if not key.startswith("MYSQL") and key not in ("TZ", "HOME", "LANG", "LC_ALL")}
        self.env.update({"HOME": str(self.base), "TZ": "UTC", "LANG": "en_US.UTF-8",
                         "MYSQL_TEST_LOGIN_FILE": str(self.base / "no-login-paths.cnf")})
        for pattern in ("*.sql", "*.cnf", "*.csv", "*.txt"):
            for f in Path(".").glob(pattern):
                shutil.copy(f, self.base / f.name)
        return self

    def __exit__(self, *exc):
        if self.proc and self.proc.poll() is None:
            self.stop(quiet=True)
        shutil.rmtree(self.base, ignore_errors=True)

    def _write_client_cnf(self, password):
        lines = ["[client]", "user=root", "host=127.0.0.1", f"port={self.port}", "protocol=TCP"]
        if password:
            lines.append(f"password={password}")
        self.client_cnf.write_text("\n".join(lines) + "\n")
        self.client_cnf.chmod(0o600)

    def server_options(self):
        b = self.base
        return ["--no-defaults", f"--basedir={self.basedir}", f"--datadir={b / 'data'}", f"--tmpdir={b / 'tmp'}",
                f"--log-error={b / 'mysqld.err'}", f"--pid-file={b / 'mysqld.pid'}",
                f"--secure-file-priv={b / 'files'}",
                *([f"--innodb-redo-log-capacity={self.redo}"] if self.redo else [])]

    # ---- the server ------------------------------------------------------------------------------------------------
    def initialize(self, show=False):
        """mysqld --initialize-insecure: a new data directory whose root account has no password yet."""
        args = ["mysqld", *self.server_options(), "--initialize-insecure"]
        code, out = self._call(args)
        if show:
            _show(["mysqld", "--no-defaults", "--initialize-insecure", "--datadir=./data"], out)
        if code != 0:
            print(self.log())
            sys.exit(f"mysqld --initialize-insecure ended with status {code}")

    def start(self, extra=(), defaults_file=None, expect_fail=False, show=False):
        """Starts mysqld on 127.0.0.1 and waits until it answers. With expect_fail, prints the error-log lines of a
        start that must fail, and returns them."""
        log = self.base / "mysqld.err"
        before = log.read_text() if log.exists() else ""
        opts = self.server_options()
        if defaults_file:
            opts[0] = f"--defaults-file={self.base / defaults_file}"
        args = [str(self.bin / "mysqld"), *opts, "--bind-address=127.0.0.1", f"--port={self.port}",
                "--socket=mysql.sock", "--mysqlx=OFF", *self.options, *[str(e) for e in extra]]
        if show:
            shown = ["mysqld", opts[0].replace(str(self.base) + "/", ""), "--datadir=./data", "--bind-address=127.0.0.1",
                     "--port=$PORT", "--socket=mysql.sock", "--mysqlx=OFF", *self.options, *extra]
            print("$ " + " ".join(_arg(a) if a != "--port=$PORT" else a for a in shown) + " &")
            print()
        with open(self.base / "mysqld.out", "a") as out:
            self.proc = subprocess.Popen(args, stdout=out, stderr=subprocess.STDOUT, cwd=self.base, env=self.env)
        for _ in range(600):
            if self.proc.poll() is not None:
                break
            if self._ping():
                break
            time.sleep(0.1)
        if self.proc.poll() is not None:
            new = (log.read_text() if log.exists() else "")[len(before):]
            lines = [l for l in self.clean(LOG_PREFIX.sub("", new)).splitlines() if "[ERROR]" in l]
            if expect_fail:
                print("\n".join(lines))
                print(f"(mysqld ended with status {self.proc.returncode})")
                print()
                self.proc = None
                return lines
            print("\n".join(lines))
            sys.exit(f"mysqld ended with status {self.proc.returncode} while starting")
        if expect_fail:
            sys.exit("mysqld started, but the session expected it to fail")
        if not self._ping():
            sys.exit("mysqld did not answer within 60 seconds")
        return []

    def _ping(self):
        code, _ = self._call(["mysqladmin", f"--defaults-file={self.client_cnf}", "--connect-timeout=2", "ping"])
        return code == 0

    def up(self, extra=(), defaults_file=None, show=False):
        """Initialise, start (with start()'s options), and give root the run's password, quietly."""
        self.initialize()
        self.start(extra=extra, defaults_file=defaults_file, show=show)
        return self.login()

    def login(self):
        """Gives root the run's password (never printed) on a server started from a new data directory, and makes
        every later client of the session use it. If this server has a label, its random server_uuid is masked to a
        stable value so GTID sets read the same on every run. A server started with --super-read-only=ON (a replica)
        blocks even root's ALTER USER, so super_read_only is turned off for the one password statement and restored.
        The statement runs with sql_log_bin off, for two reasons: the binary log a lesson decodes then holds only the
        lesson's own statements, and the replica keeps its own root password (each server of a run has a different one,
        so a replicated ALTER USER would lock the session out of the replica)."""
        sro = self.query("SELECT @@global.super_read_only") == "1"
        if sro:
            self.query("SET GLOBAL super_read_only=OFF")
        self.silent("SET SESSION sql_log_bin = 0;\n"
                    f"ALTER USER 'root'@'localhost' IDENTIFIED BY '{self.password}'")
        self._write_client_cnf(self.password)
        if sro:
            self.query("SET GLOBAL super_read_only=ON")
        if self.label:
            self.uuid = self.query("SELECT @@server_uuid")
            self.mask(re.escape(self.uuid), f"<{self.label}-uuid>")
        return self

    def stop(self, quiet=False):
        """A clean shutdown (mysqladmin shutdown), then waits until the process has ended."""
        code, out = self._call(["mysqladmin", f"--defaults-file={self.client_cnf}", "shutdown"])
        if code != 0 and not quiet:
            sys.exit(f"mysqladmin shutdown failed: {out}")
        try:
            self.proc.wait(timeout=120)
        except subprocess.TimeoutExpired:
            self.proc.terminate()
            self.proc.wait(timeout=60)
        self.proc = None

    def restart(self, extra=(), defaults_file=None, show=False):
        self.stop()
        self.start(extra=extra, defaults_file=defaults_file, show=show)

    # ---- running things --------------------------------------------------------------------------------------------
    def _call(self, args, stdin=None, merge=True, clean=True):
        """Runs one of MySQL's programs in the run folder: (status, cleaned output). merge=False keeps only what it
        wrote to standard output (its warnings on standard error are dropped). clean=False returns the output exactly
        as the program wrote it, for a value the session feeds back into SQL (a GTID set, a file name): a masked value
        is text for the record only and MySQL would reject it."""
        args = [str(a) for a in args]
        done = subprocess.run([str(self.bin / args[0]), *args[1:]], stdout=subprocess.PIPE,
                              stderr=subprocess.STDOUT if merge else subprocess.DEVNULL,
                              text=True, cwd=self.base, env=self.env, input=stdin)
        return done.returncode, (self.clean(done.stdout) if clean else done.stdout)

    def output(self, *args, connect=False, status=0):
        """Runs one of MySQL's programs without printing anything and returns its standard output, for example the
        SQL that mysql_tzinfo_to_sql writes, which the session saves with write_file(..., show=False) and runs next."""
        args = [str(a) for a in args]
        run = [args[0], f"--defaults-file={self.client_cnf}", *args[1:]] if connect else args
        code, out = self._call(run, merge=False)
        if code != status:
            sys.exit(f"{args[0]} ended with status {code}, not {status}")
        return out

    def clean(self, text):
        """The substitutions of the docstring. A table of the client whose cells change is drawn again with the
        new column widths, so its borders still line up."""
        lines, out, i = text.split("\n"), [], 0
        while i < len(lines):
            if lines[i].startswith("+-"):
                j = i
                while j < len(lines) and lines[j][:1] in ("+", "|"):
                    j += 1
                block = lines[i:j]
                done = [self._clean(l) for l in block]
                out.extend(done if done == block else _redraw(block, self._clean))
                i = j
            else:
                out.append(self._clean(lines[i]))
                i += 1
        return "\n".join(out)

    def _clean(self, text):
        text = text.replace(str(self.bin) + "/", "").replace(str(self.basedir), "<basedir>")
        text = text.replace(str(self.base) + "/", "./").replace(str(self.base), ".")
        text = re.sub(r" \(\d+\.\d{2,3} sec\)", "", text)  # 8.4 prints 2 decimals, 9.7 and 26.7 print 3
        text = re.sub(r'"Timestamp": \d+', '"Timestamp": <when it was set>', text)
        text = re.sub(rf"\b{re.escape(HOST)}\b", "lab-host", text)
        text = re.sub(rf"\b{re.escape(OS_USER)}\b", "learner", text)
        text = re.sub(rf"(?<![\d.]){self.port}(?![\d.])", self.port_label or "<port>", text)
        for pattern, replacement in self.masks:
            text = pattern.sub(replacement, text)
        return text

    def log(self, pattern=None):
        """The error log without timestamps and thread numbers, optionally only the lines matching a pattern."""
        text = self.clean(LOG_PREFIX.sub("", (self.base / "mysqld.err").read_text()))
        if pattern:
            text = "\n".join(l for l in text.splitlines() if re.search(pattern, l))
        return text

    def show_log(self, pattern, note=None):
        """Prints the error-log lines that match a pattern, as grep would."""
        print(f"$ grep -E {_arg(pattern)} mysqld.err" + (f"   # {note}" if note else ""))
        print(self.log(pattern))
        print()

    def query(self, sql, db=None):
        """One value from one query, unprinted and UNMASKED (the session uses it to wait or to decide, and often
        feeds it back into SQL: a masked value would be a syntax error). Use gtid_executed() or clean() for a value
        that goes into the record."""
        args = ["mysql", f"--defaults-file={self.client_cnf}", "-N", "-B", *([db] if db else [])]
        code, out = self._call(args, stdin=sql, clean=False)
        if code != 0:
            sys.exit(f"the query failed: {self.clean(out)}")
        return out.strip()

    def silent(self, sql):
        """Runs a statement without printing it (used only for the run's password)."""
        self.query(sql)

    def mysql(self, script, errors=0, status=None, db=None, flags=("-t", "-vvv", "--skip-comments"), note=None, force=None):
        """Runs a client script from standard input and prints the command and its output. `errors` is how many
        ERROR lines the script must print; with errors > 0 the client runs on past an error (--force) and then ends
        with status 0, without --force it stops at the first error with status 1. The default flags print each
        statement (-vvv) as a table (-t) without the script's comments (--skip-comments)."""
        force = errors > 0 if force is None else force
        shown = ["mysql", *flags, *(["--force"] if force else []), *([db] if db else [])]
        args = ["mysql", f"--defaults-file={self.client_cnf}", *shown[1:]]
        code, out = self._call(args, stdin=(self.base / script).read_text())
        if note:
            print(f"-- {note}")
        print("$ " + " ".join(_arg(a) for a in shown) + f" < {script}")
        if out.strip():
            print(out.rstrip("\n"))
        print()
        found = sum(1 for line in out.splitlines() if line.startswith("ERROR "))
        expected_status = (1 if errors and not force else 0) if status is None else status
        if found != errors or code != expected_status:
            sys.exit(f"{script}: {found} errors and status {code}; the session expects {errors} and {expected_status}")
        return out

    def shell(self, *args, status=0, shown=None, connect=False):
        """Runs one of MySQL's programs and prints the command and its output. connect=True gives a client program
        (mysqladmin, mysqlshow, mysql) the run's client option file, which the printed command leaves out."""
        args = [str(a) for a in args]
        run = [args[0], f"--defaults-file={self.client_cnf}", *args[1:]] if connect else args
        code, out = self._call(run)
        _show(shown or args, out)
        if code != status:
            sys.exit(f"{args[0]} ended with status {code}, not {status}")
        return out

    def sql(self, statement, flags=("-t",), errors=0, db=None, note=None):
        """Runs one statement with mysql -e and prints the command and its output."""
        shown = ["mysql", *flags, *([db] if db else []), "-e", statement]
        code, out = self._call(["mysql", f"--defaults-file={self.client_cnf}", *shown[1:]])
        if note:
            print(f"-- {note}")
        _show(shown, out)
        found = sum(1 for line in out.splitlines() if line.startswith("ERROR "))
        if found != errors or (code != 0) != (errors > 0):
            sys.exit(f"{statement}: {found} errors and status {code}; the session expects {errors}")
        return out

    def write_file(self, path, text, show=True):
        """Writes a text file into the run folder (an option file, a script) and prints it, as cat would."""
        (self.base / path).write_text(textwrap.dedent(text).lstrip("\n"))
        if show:
            self.show_file(path)

    def show_file(self, path):
        print(f"$ cat {path}")
        print(self.clean((self.base / path).read_text()).rstrip("\n"))
        print()

    def listing(self, folder, prefix=""):
        """Prints the names in a folder of the run (sorted, folders with a slash), as `ls -p` would list them."""
        path = self.base / folder
        names = sorted(n + ("/" if (path / n).is_dir() else "") for n in os.listdir(path) if n.startswith(prefix))
        print(f"$ ls -p {folder}" + (f" | grep '^{prefix}'" if prefix else ""))
        print(textwrap.fill("  ".join(names), width=78, break_on_hyphens=False))
        print()
        return names


    # ---- replication (two or three servers on 127.0.0.1) -----------------------------------------------------------
    def gtid_executed(self, raw=False):
        """This server's @@global.gtid_executed as one line (no newlines). The value is masked for the record (the
        server_uuid reads as <label-uuid>); raw=True gives the real set, which is what MySQL accepts in a statement."""
        value = self.query("SELECT @@global.gtid_executed").replace("\n", "")
        return value if raw else self.clean(value)

    def track(self, other):
        """Teach this server's record to mask another server's port and server_uuid, so a statement that names the
        other server (CHANGE REPLICATION SOURCE TO, SHOW REPLICA STATUS) reads the same on every run."""
        if other.port:
            self.mask(rf"(?<![\d.]){other.port}(?![\d.])", other.port_label or "<port>")
        if other.uuid:
            self.mask(re.escape(other.uuid), f"<{other.label}-uuid>")
        self.mask(re.escape(str(other.base)), ".")

    def change_source(self, source, user="repl", password="replpass", auto_position=True, ssl=False,
                      verify_ca=False, channel=None, delay=None, extra=(), show=True, run=True):
        """Builds and (run=True) runs CHANGE REPLICATION SOURCE TO on this replica, pointing at `source`. With
        verify_ca the source's auto-generated ca.pem is copied into the replica's data directory and the replica
        verifies the server certificate's issuer chains to it (OpenSSL's VERIFY_CA mode).
        A relative SOURCE_SSL_CA is resolved relative to the replica's data directory, so the file is copied there,
        not into the run folder.
        VERIFY_IDENTITY (SOURCE_SSL_VERIFY_SERVER_CERT=1, which also checks the server certificate's SAN against the
        host) is not wired up here: MySQL's --initialize-insecure auto-generates self-signed certificates whose
        subject is "MySQL_Server_<version>_Auto_Generated_Server_Certificate" and whose SAN does not include
        127.0.0.1, so VERIFY_IDENTITY would fail with "certificate verify failed". A production setup issues server
        certificates whose SAN includes the host and then enables SOURCE_SSL_VERIFY_SERVER_CERT=1."""
        self.track(source)
        opts = ["SOURCE_HOST='127.0.0.1'", f"SOURCE_PORT={source.port}", f"SOURCE_USER='{user}'",
                f"SOURCE_PASSWORD='{password}'"]
        if auto_position:
            opts.append("SOURCE_AUTO_POSITION=1")
        if ssl:
            opts.append("SOURCE_SSL=1")
        if verify_ca:
            shutil.copy(source.base / "data" / "ca.pem", self.base / "data" / "source-ca.pem")
            opts.append("SOURCE_SSL_CA='source-ca.pem'")
        if delay is not None:
            opts.append(f"SOURCE_DELAY={delay}")
        opts += [str(e) for e in extra]
        stmt = "CHANGE REPLICATION SOURCE TO\n  " + ",\n  ".join(opts)
        if channel:
            stmt += f"\n  FOR CHANNEL '{channel}'"
        stmt += ";"
        self.write_file("_change_source.sql", stmt + "\n", show=False)
        if run:
            if show:
                self.mysql("_change_source.sql")
            else:
                self.query(stmt)
        return stmt

    def start_replica(self, user=None, password=None, channel=None, show=True):
        """START REPLICA, optionally with USER/PASSWORD given at start time (not stored in the metadata repository)."""
        parts = ["START REPLICA"]
        if user:
            parts.append(f"USER='{user}' PASSWORD='{password}'")
        if channel:
            parts.append(f"FOR CHANNEL '{channel}'")
        stmt = " ".join(parts) + ";"
        if show:
            self.write_file("_start_replica.sql", stmt + "\n", show=False)
            self.mysql("_start_replica.sql")
        else:
            self.query(stmt)

    def stop_replica(self, channel=None, show=False):
        stmt = "STOP REPLICA" + (f" FOR CHANNEL '{channel}'" if channel else "")
        if show:
            self.sql(stmt)
        else:
            self.query(stmt)

    def wait_replica_running(self, channel=None, timeout=30):
        """Waits until both replication threads report ON in performance_schema (deterministic, no fixed sleep)."""
        where = f" WHERE CHANNEL_NAME='{channel}'" if channel else ""
        for _ in range(timeout * 10):
            io = self.query(f"SELECT SERVICE_STATE FROM performance_schema.replication_connection_status{where}")
            applier = self.query(f"SELECT SERVICE_STATE FROM performance_schema.replication_applier_status{where}")
            if io == "ON" and applier == "ON":
                return
            time.sleep(0.1)
        print(self.sql("SHOW REPLICA STATUS", flags=("-E",)))
        sys.exit("the replica did not reach the running state in time")

    def wait_caught_up(self, source, timeout=30):
        """Blocks until this replica has executed every transaction the source had committed when called."""
        gset = source.gtid_executed(raw=True)
        if not gset:
            return
        result = self.query(f"SELECT WAIT_FOR_EXECUTED_GTID_SET('{gset}', {timeout})")
        if result != "0":
            sys.exit(f"the replica did not catch up (WAIT_FOR_EXECUTED_GTID_SET returned {result})")

    def replica_status(self, fields, channel=None, note=None, label="SHOW REPLICA STATUS"):
        """Prints only the named SHOW REPLICA STATUS fields, in the order given, vertically (phone-friendly)."""
        stmt = "SHOW REPLICA STATUS" + (f" FOR CHANNEL '{channel}'" if channel else "")
        code, out = self._call(["mysql", f"--defaults-file={self.client_cnf}", "-E", "-e", stmt])
        if code != 0:
            sys.exit(f"SHOW REPLICA STATUS failed: {out}")
        values = {}
        for line in out.splitlines():
            m = re.match(r"\s*([A-Za-z_]+):\s?(.*)$", line)
            if m:
                values[m.group(1)] = m.group(2)
        if note:
            print(f"-- {note}")
        print(f"mysql> {label}\\G")
        for field in fields:
            if field in values:
                print(f"  {field}: {values[field]}")
        print()
        return values


def _redraw(block, clean):
    """Draws a client table again after its cells were cleaned. The columns come from the first border line; the row
    after it is the header (left-aligned); a column whose data cells were padded on the left (numbers) stays
    right-aligned (an empty cell says nothing about its column)."""
    cuts = [k for k, ch in enumerate(block[0]) if ch == "+"]
    rows = [None if line.startswith("+") else [line[x + 1:y] for x, y in zip(cuts, cuts[1:])] for line in block]
    header = next(k for k, r in enumerate(rows) if r is not None)
    ncol = len(cuts) - 1
    right = [any(r is not None and k != header and r[c].strip() and r[c].startswith("  ") for k, r in enumerate(rows))
             for c in range(ncol)]
    cells = [None if r is None else [clean(x.strip()) for x in r] for r in rows]
    widths = [max(len(r[c]) for r in cells if r is not None) for c in range(ncol)]
    drawn = []
    for k, r in enumerate(cells):
        if r is None:
            drawn.append("+" + "+".join("-" * (w + 2) for w in widths) + "+")
        else:
            drawn.append("|" + "|".join(" " + (t.rjust(w) if right[c] and k != header else t.ljust(w)) + " "
                                        for c, (t, w) in enumerate(zip(r, widths))) + "|")
    return drawn


def _arg(a):
    """A command-line argument as a reader would type it: quotes only where the shell needs them."""
    a = str(a)
    if re.fullmatch(r"[\w@%+=:,./-]+", a):
        return a
    if not re.search(r"[\"$`\\!]", a):
        return f'"{a}"'
    return shlex.quote(a)


def _show(args, out):
    print("$ " + " ".join(_arg(a) for a in args))
    if out.strip():
        print(out.rstrip("\n"))
    print()

The recorded run shows four facts in order. SHOW GRANTS FOR 'migrator'@'localhost' names the roles the account holds; SHOW GRANTS ... USING 'app_ddl', 'app_rw' adds the lines the roles grant. reporting holds app_read yet CURRENT_ROLE() on login is NONE, so a SELECT on shop.orders is denied until SET ROLE 'app_read' turns it on for the session. SET DEFAULT ROLE 'app_read' TO 'reporting'@'localhost' then records a default role: A role recorded for an account with SET DEFAULT ROLE, which MySQL activates each time the account logs in; without one, and with activate_all_roles_on_login off (the default), a granted role stays inactive until the session runs SET ROLE. that activates at every login, which the next session proves by running the read and failing the DELETE. A role never grants more than it holds. The global activate_all_roles_on_login flag turns every granted role on at every login for every account; the recorded line shows oncall with app_read active after the switch.

Checkpoint

Check: Roles, default roles and SHOW GRANTS USING

  1. Question 1 of 2 In roles.sql, SELECT User, Host, account_locked FROM mysql.user WHERE User LIKE 'app%' shows what for the three roles app_rw, app_ddl and app_read?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: account_locked = Y on every row

    A role is an account whose row sits in mysql.user with account_locked = Y, no password, no login allowed. Granting a role is granting what the role carries without opening a new login path.

  2. Question 2 of 2 Why does reporting, which has been GRANT 'app_read' TO 'reporting'@'localhost', see ERROR 1142 on a SELECT right after a fresh login?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: No role is active at login until a default role is set or SET ROLE turns one on in the session

    A granted role is inactive until a session turns it on. SET DEFAULT ROLE 'app_read' TO 'reporting'@'localhost' activates it at every login; activate_all_roles_on_login = ON would do the same for every account.

Narrower grants with partial revokes, and SYSTEM_USER for admin accounts Pro: opens with a Pro, Premium or Ultimate pass

The lesson’s first half gave each account the rights of its job. Two features let a design hold up when the shape of the job is wider than a single schema: a partial revoke keeps a global grant out of one schema, and SYSTEM_USER turns an admin account into one that only another SYSTEM_USER can touch.

In this part:

  • A partial revoke carves a schema out of a global grant
  • How one privilege shields admin accounts from ordinary operators
  • Key takeaways from this part

1 runnable example (mysql_session.py, partial_revokes.sql, partial_revokes_session.py, schemas.sql) · about 8 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Definer views without their owner, and queries that find over-broad grants Pro: opens with a Pro, Premium or Ultimate pass

A view or stored program runs its body as some account, and that account, not the caller, can decide which rows the caller reaches. Recorded runs compare a DEFINER view with an INVOKER view, follow a definer account the server refuses to drop, and turn seven queries into an audit of who can do what.

In this part:

  • A SQL SECURITY DEFINER view exposes aggregates without the rows
  • When a definer account goes: the refusal, the check and the fix
  • An audit query set that finds over-broad grants
  • Finish on the exercise: privilege puzzles
  • Predict which SELECT each account can run from its grants
    • Results of the sample tests
  • Key takeaways on definer views and audits

3 runnable examples (audit_grants.sql, audit_grants_session.py, audit_setup.sql, definers.sql, definers_session.py, mysql_session.py, orphans.sql, overbroad.sql, overbroad_session.py, repoint.sql, schemas.sql) · 1 exercise with sample tests · about 15 minutes

Opening this part with your pass…Pro part. It opens with the same pass as the part above.

Practise: the privilege model, the five accounts and the roles

Each question comes back to the opening’s shapes, so a reader who has read it answers from the facts.

Practice that adapts to you

Practise this lesson

6 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 6 Which table holds the dynamic privileges MySQL grants?

    Choose one answer.

    Show the answer: Question 1 of 6

    Answer: mysql.global_grants

    Dynamic privileges are rows of mysql.global_grants (USER, HOST, PRIV, WITH_GRANT_OPTION); the recorded run counts 40 such rows for root@localhost on 8.4.11.

  2. Question 2 of 6 GRANT SUPER ON *.* TO 'backup_bot'@'localhost' prints what on SHOW WARNINGS in privileges.sql?

    Choose one answer.

    Show the answer: Question 2 of 6

    Answer: The SUPER privilege identifier is deprecated (code 1287)

    SUPER keeps working but prints warning 1287. The replacement set is a handful of narrower dynamic privileges (CONNECTION_ADMIN, SYSTEM_VARIABLES_ADMIN, SESSION_VARIABLES_ADMIN and so on).

  3. Question 3 of 6 Which single dynamic privilege does the backup account of accounts.sql hold on top of its global read grants?

    Choose one answer.

    Show the answer: Question 3 of 6

    Answer: FLUSH_TABLES

    FLUSH_TABLES is a dynamic privilege narrower than RELOAD. The recorded run shows mysqldump failing with ERROR 1227 right after REVOKE FLUSH_TABLES ON *.* FROM 'backup'@'localhost'.

  4. Question 4 of 6 A new reporting account must reach the primary from the 10.0.0.0/24 subnet, read shop, and refuse any plain-text login. Which CREATE statement fits the five-account shape of this lesson?

    Choose one answer.

    Show the answer: Question 4 of 6

    Answer: CREATE USER 'reporting'@'10.0.0.%' IDENTIFIED BY '...' REQUIRE SSL; plus GRANT SELECT ON shop.* TO 'reporting'@'10.0.0.%'

    A per-account REQUIRE SSL fits with the five account shapes: host scoped to the network, grants scoped to the job, and TLS as a connection rule the account itself refuses to drop.

  5. Question 5 of 6 Which statement activates a role for every later login of one account?

    Choose one answer.

    Show the answer: Question 5 of 6

    Answer: SET DEFAULT ROLE 'app_read' TO 'reporting'@'localhost'

    SET DEFAULT ROLE records which role (or roles) the next session opens with for one account. The recorded run shows reporting logging in after the statement with CURRENT_ROLE() already reading app_read.

  6. Question 6 of 6 What does SHOW GRANTS FOR 'migrator'@'localhost' USING 'app_ddl', 'app_rw' print, compared with the same statement without USING?

    Choose one answer.

    Show the answer: Question 6 of 6

    Answer: The same lines plus the grants the two roles carry, so a reader sees exactly what the account can do while those roles are active

    USING tells SHOW GRANTS which roles to expand into the account's own grants. The output is a complete picture of what the account can do when those roles are active.

Interview questions

The three important questions below extend the opening into a design conversation; the warm-up answers one plain question on the model.

Warm-up: what makes a privilege dynamic rather than static?

A static privilege is built into the server and is always available to grant; its global and schema grants are Y/N columns of mysql.user and mysql.db, and its table and column grants are sets in mysql.tables_priv and mysql.columns_priv. A dynamic privilege is registered at run time, most by the server at startup and the rest by the component or plugin that defines it; it is held per account as a row of mysql.global_grants and grants only at the global level: GRANT BACKUP_ADMIN ON shop.* returns ERROR 3619. The recorded run of privileges.sql shows the counts on MySQL 8.4.11, 31 static global columns and 40 dynamic rows for root, and the refusal of BACKUP_ADMIN at the schema level.

  1. Mid to senior

    A new team of five developers needs database accounts on a MySQL 8.4 server that holds application rows, a reporting replica of them and a payroll schema. Design the privilege set each account carries, the roles that group the privileges, and the one line that keeps payroll out of the operators' global SELECT.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Senior to staff

    A compliance review asks that no application account read payroll schemas, even though a handful of accounts inherited a global SELECT from a long-gone migration. You cannot drop and recreate every account during the week of the review. Plan the roll-out of partial revokes across one source and six replicas, and the proof you can hand to the auditor.

    Model answer, Pro. First step and follow-ups

  3. Mid to senior

    During an incident, an operator asks you which accounts can read salaries. The server has twenty accounts, a handful of roles, a few partial revokes and three definer views on payroll.salaries. Write the audit that answers the question completely, with the queries you run and the way you read them.

    Model answer, Pro. First step and follow-ups

Flashcards · 7 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. How does MySQL 8.4 store static and dynamic privileges?

    Static privileges are columns, 31 global _priv columns of mysql.user on 8.4.11. Dynamic ones, registered at run time, are rows of mysql.global_grants (40 for root) and grant only globally: GRANT BACKUP_ADMIN ON shop.* returns ERROR 3619.

  2. Why does GRANT FIREWALL_ADMIN ON *.* fail as a syntax error on a Community server?

    A dynamic privilege nothing has registered cannot be granted, and the firewall that would register this one ships only in the Enterprise build, so the server answers ERROR 1149.

  3. What does least privilege look like for MySQL accounts?

    Each account holds only the grants of its one job, scoped to the hosts it logs in from, with REQUIRE SSL when it crosses a shared network: the application reads and writes its schema, the migrator adds schema changes, reporting reads, backup reads with FLUSH_TABLES, and replication holds one grant.

  4. Why does a backup account need the FLUSH_TABLES privilege?

    mysqldump --single-transaction on a server with GTIDs on runs a FLUSH TABLES; after REVOKE FLUSH_TABLES the dump failed at that step with ERROR 1227.

  5. An account holds a role yet its SELECT is denied after login. Why?

    A granted role is inactive until SET ROLE turns it on for the session, or SET DEFAULT ROLE (or activate_all_roles_on_login) activates it at login: CURRENT_ROLE() read NONE in the run until then.

  6. What is a MySQL role, and how do you read what it grants?

    A locked account with no password that groups privileges, granted to accounts so the accounts stay unchanged when a job changes. SHOW GRANTS FOR an account names its roles; adding USING with the role names lists what they grant.

  7. Does the SUPER privilege still work on MySQL 8.4?

    Yes, but it is deprecated: using it leaves a warning saying the SUPER privilege identifier is deprecated.

Key takeaways

  • MySQL keeps static privileges in columns (31 global _priv columns of mysql.user on 8.4.11) and dynamic privileges in rows of mysql.global_grants (40 for root); dynamic privileges only grant at the global level.
  • Five least-privilege account shapes keep each job separate: the application reads and writes its schema, the migrator adds schema changes, reporting reads, backup reads with FLUSH_TABLES, replication holds one grant and REQUIRE SSL.
  • A role is a locked account with no password that groups privileges; grant the role to accounts, set a default role for the session to open with the right active, and read what a role carries with SHOW GRANTS ... USING.

Terms in this lesson

default role
A role recorded for an account with SET DEFAULT ROLE, which MySQL activates each time the account logs in; without one, and with activate_all_roles_on_login off (the default), a granted role stays inactive until the session runs SET ROLE.
dynamic privilege
A privilege registered at run time, most by the server at startup and the rest by the component or plugin that defines it; held per account as a row of mysql.global_grants and granted only at the global level, ON *.*.
least privilege
The design rule that every account carries only the privileges it needs for its job, scoped to the hosts it logs in from, with wider grants replaced by schema- or table-level ones and risky dynamic privileges held only by named accounts.
partial revoke
A REVOKE at the schema level that narrows one account's global grant, so that, for example, a global SELECT leaves out one schema.
role
A named account in mysql.user with account_locked=Y and no password that groups privileges together; grant the role to accounts, activate it per session with SET ROLE or at login with a default role.
static privilege
A privilege built into the MySQL server and always available to grant; its global and schema grants are Y/N columns of mysql.user and mysql.db, its table and column grants sets in mysql.tables_priv and mysql.columns_priv.
SYSTEM_USER privilege
A dynamic privilege that marks an account as a system account, which an ordinary administrator without SYSTEM_USER cannot modify, so the holders of SYSTEM_USER form the server's smaller administrator set.

References

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.