Skip to content

Commit e9dfbeb

Browse files
authored
Merge pull request #5810 from sysown/docs/passthrough-auth-spec
feat(passthrough-auth): pass-through authentication (spec + Phase 1, WIP)
2 parents 71df1e0 + 3b5e401 commit e9dfbeb

34 files changed

Lines changed: 5848 additions & 4 deletions

CHANGELOG.md

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,73 @@
22

33
## Version 3.0.x Series - PostgreSQL Support Introduction
44

5+
### Unreleased
6+
7+
#### Pass-Through Authentication (Phase 1 — opt-in, MySQL only)
8+
9+
New authentication mode that lets a MySQL client authenticate through
10+
ProxySQL without a pre-provisioned password in `mysql_users`. ProxySQL
11+
intercepts the cleartext password during the `caching_sha2_password`
12+
*full-auth* exchange, probes the configured backend with it, and on
13+
success caches the credential for subsequent connects. See PR #5810 and
14+
`doc/internal/passthrough_authentication.md` for the design.
15+
16+
Feature is **off by default**. Existing deployments see no behavioral
17+
change unless an operator explicitly enables it.
18+
19+
To opt in (one of):
20+
21+
- Provision a `mysql_users` row with `password=''` (empty) and set
22+
`mysql-passthrough_auth_enabled=true`. The empty-password row now
23+
semantically means "learn the password from the first successful
24+
caching_sha2_password connect" (it no longer permits passwordless
25+
login when the master gate is on — documented behavior change).
26+
- Set `mysql-passthrough_auth_unknown_users=true` to also pass-through
27+
usernames that don't exist in `mysql_users` at all. Routing comes
28+
from `mysql-passthrough_default_hg`. **SECURITY**: this combination
29+
without `mysql-passthrough_auth_username_pattern` set is the most
30+
dangerous pass-through configuration — ProxySQL will emit a WARNING
31+
at `LOAD MYSQL VARIABLES TO RUNTIME` flagging the operator.
32+
33+
New globals (13):
34+
- `mysql-passthrough_auth_enabled` (bool, default `false`)
35+
- `mysql-passthrough_auth_empty_password` (bool, default `true`)
36+
- `mysql-passthrough_auth_unknown_users` (bool, default `false`)
37+
- `mysql-passthrough_auth_require_tls` (bool, default `true`)
38+
- `mysql-passthrough_default_hg` (int, default `0`)
39+
- `mysql-passthrough_default_schema` (str, default `""`)
40+
- `mysql-passthrough_auth_cache_ttl_s` (int, default `0` = no expiry)
41+
- `mysql-passthrough_auth_max_inflight_probes` (int, default `100`)
42+
- `mysql-passthrough_auth_username_pattern` (str, default `""` = allow all; re2 regex allowlist)
43+
- `mysql-passthrough_auth_max_failures_per_user` (int, default `3`)
44+
- `mysql-passthrough_auth_max_failures_per_ip` (int, default `10`)
45+
- `mysql-passthrough_auth_failure_window_s` (int, default `60`)
46+
- `mysql-passthrough_auth_failure_map_cap` (int, default `100000`)
47+
48+
New admin commands:
49+
- `PROXYSQL FLUSH PASSTHROUGH_AUTH_CACHE [FOR USER '<name>']`
50+
51+
New stats virtual tables:
52+
- `stats_mysql_passthrough_auth_cache` (entries, no passwords)
53+
- `stats_mysql_passthrough_auth_metrics` (9 counters + 2 gauges)
54+
55+
New audit events:
56+
- `MySQL_Client_Connect_Passthrough_OK`
57+
- `MySQL_Client_Connect_Passthrough_FAIL`
58+
59+
Phase 1 limitations (documented in code + spec):
60+
- `caching_sha2_password` only (`mysql_clear_password` → Phase 2;
61+
`mysql_native_password` → Phase 3)
62+
- Synchronous backend probe (matches MySQL_Monitor's pattern;
63+
pinning a worker thread for the duration of one backend handshake)
64+
- One-shot probe connections do not go through the connection pool
65+
(don't count against `mysql_servers.max_connections`; the effective
66+
ceiling is `mysql-passthrough_auth_max_inflight_probes`)
67+
- No persistence; restart = cold cache
68+
- `COM_CHANGE_USER` targeting a pass-through user is rejected
69+
- Cluster sync of variables works via the standard `mysql_variables`
70+
path; metric counters are per-node
71+
572
### v3.0.2 (2025-08-06)
673
#### PostgreSQL Enhancements
774
- Improved error processing and reporting format for backend connections using libpq (#4947)

doc/internal/passthrough_authentication.md

Lines changed: 509 additions & 0 deletions
Large diffs are not rendered by default.

etc/proxysql.cnf

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,35 @@ mysql_variables=
8282
commands_stats=true
8383
sessions_sort=true
8484
connect_retries_on_failure=10
85+
86+
#####################################################################
87+
# Pass-through authentication (opt-in, off by default).
88+
#
89+
# When enabled, ProxySQL can authenticate a client to the backend
90+
# without a pre-provisioned password in mysql_users: it intercepts
91+
# the cleartext password sent during the caching_sha2_password
92+
# full-auth exchange, probes a backend with it, and on success
93+
# caches the credential in memory for the next connect. See
94+
# doc/internal/passthrough_authentication.md for the design.
95+
#
96+
# IMPORTANT: enabling passthrough_auth_unknown_users WITHOUT setting
97+
# passthrough_auth_username_pattern lets ANY username be probed
98+
# against the backend. ProxySQL emits a startup warning when that
99+
# combination is published; treat the warning seriously.
100+
#####################################################################
101+
# passthrough_auth_enabled=false # master gate; nothing below applies until true
102+
# passthrough_auth_empty_password=true # honor mysql_users rows with password='' as pass-through opt-in
103+
# passthrough_auth_unknown_users=false # also allow usernames NOT in mysql_users (gated by passthrough_default_hg)
104+
# passthrough_auth_require_tls=true # refuse to send AuthMoreData{0x04} over non-TLS client connections
105+
# passthrough_default_hg=0 # hostgroup used for unknown-user probes
106+
# passthrough_default_schema="" # default schema for unknown-user sessions (falls back to default_schema if empty)
107+
# passthrough_auth_cache_ttl_s=0 # 0 = entries never expire; nonzero = lazy eviction window in seconds
108+
# passthrough_auth_max_inflight_probes=100 # concurrent probes cap (DoS / thundering-herd protection)
109+
# passthrough_auth_username_pattern="" # re2 regex; empty = allow all (HIGHLY recommended to set when unknown_users=true)
110+
# passthrough_auth_max_failures_per_user=3 # per-user sliding-window threshold
111+
# passthrough_auth_max_failures_per_ip=10 # per-source-IP sliding-window threshold
112+
# passthrough_auth_failure_window_s=60 # sliding-window size for both counters
113+
# passthrough_auth_failure_map_cap=100000 # max distinct keys retained in failure deques (defense-in-depth)
85114
}
86115

87116

include/MySQL_Data_Stream.h

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,16 @@ class MySQL_Data_Stream
170170
// Updated **only** when an 'auth_switch' has been sent to client
171171
enum proxysql_auth_plugins switching_auth_sent;
172172
int auth_in_progress; // if 0 , no authentication is in progress. Any value greater than 0 depends from the implementation
173+
// Pass-through authentication: cleartext password received from the
174+
// client during the caching_sha2_password full-auth exchange (spec
175+
// §4.1). Captured by PPHR_passthrough_init at stage 5 and consumed by
176+
// handler_again___status_AUTHENTICATING_BACKEND_FOR_CLIENT when it
177+
// drives the non-blocking backend connect. Lives on a dedicated field
178+
// (NOT userinfo->password) because process_pkt_handshake_response's
179+
// epilogue overwrites userinfo->password with "" when auth is still in
180+
// progress -- which would clobber the borrowed cleartext before the
181+
// session handler runs. NULL when no pass-through probe is in flight.
182+
char *passthrough_cleartext;
173183
unsigned int tmp_charset;
174184

175185
short revents;

include/MySQL_Logger.hpp

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -553,6 +553,33 @@ class MySQL_Logger {
553553
*/
554554
void log_audit_entry(log_event_type _et, MySQL_Session* sess, MySQL_Data_Stream* myds, char* xi = NULL);
555555

556+
/**
557+
* @brief Emit an audit entry tagged with a hostgroup id.
558+
*
559+
* Spec §7.4 for pass-through auth requires the audit entry to include
560+
* "username, source IP, hostgroup probed, outcome". The username and
561+
* source IP fields are already populated automatically from
562+
* @p sess->client_myds. This overload threads the hostgroup that was
563+
* probed (or that drove a denied attempt) onto the underlying
564+
* MySQL_Event::hid field, which @ref MySQL_Event::write_auth then
565+
* emits as the JSON @c hostgroup field. The existing
566+
* single-parameter form continues to be used by every non-pass-through
567+
* caller and leaves @c hid as @c UINT64_MAX (omitted from the JSON).
568+
*
569+
* @param _et Event type (PROXYSQL_MYSQL_AUTH_PASSTHROUGH_OK or _FAIL).
570+
* @param sess Originating session (provides user / IP).
571+
* @param myds Backend data stream, if any. NULL for the pass-through
572+
* probe path because the probe is a one-shot
573+
* libmariadbclient connection that never becomes a
574+
* MySQL_Data_Stream.
575+
* @param xi Optional extra-info string, written verbatim into the
576+
* JSON @c extra_info field.
577+
* @param hostgroup Hostgroup id that was probed (>= 0). Use the helper
578+
* via this overload only when the value is meaningful;
579+
* pass @c -1 to fall back to the non-hostgroup form.
580+
*/
581+
void log_audit_entry(log_event_type _et, MySQL_Session* sess, MySQL_Data_Stream* myds, char* xi, int hostgroup);
582+
556583
/**
557584
* @brief Flushes the log files.
558585
*/
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
#ifndef PROXYSQL_MYSQL_PASSTHROUGH_AUTH_CACHE_H
2+
#define PROXYSQL_MYSQL_PASSTHROUGH_AUTH_CACHE_H
3+
4+
#include <pthread.h>
5+
#include <atomic>
6+
#include <cstddef>
7+
#include <cstdint>
8+
#include <deque>
9+
#include <string>
10+
#include <unordered_map>
11+
#include <vector>
12+
13+
/**
14+
* @brief Forward declaration of re2::RE2 to keep this header light.
15+
*
16+
* The full re2/re2.h pulls in a large set of headers and dependencies; we
17+
* only need a pointer to RE2 in the class state, so a forward declaration
18+
* suffices. The translation unit that owns the compiled regex
19+
* (lib/MySQL_Passthrough_Auth_Cache.cpp) includes the full header.
20+
*/
21+
namespace re2 { class RE2; }
22+
23+
#ifdef DEBUG
24+
#define MYSQL_PASSTHROUGH_AUTH_CACHE_DEB "_DEBUG"
25+
#else
26+
#define MYSQL_PASSTHROUGH_AUTH_CACHE_DEB ""
27+
#endif
28+
#define MYSQL_PASSTHROUGH_AUTH_CACHE_VERSION "0.1.0000" MYSQL_PASSTHROUGH_AUTH_CACHE_DEB
29+
30+
struct passthrough_entry_view {
31+
std::string username;
32+
uint64_t learned_at_us;
33+
int hostgroup_probed;
34+
};
35+
36+
class MySQL_Passthrough_Auth_Cache {
37+
private:
38+
struct entry_t {
39+
std::string cleartext_password;
40+
uint64_t learned_at_us;
41+
int hostgroup_probed;
42+
};
43+
mutable pthread_rwlock_t lock;
44+
std::unordered_map<std::string, entry_t> entries;
45+
std::atomic<int> inflight_probes;
46+
47+
/**
48+
* @brief Atomic counters for operational observability (spec §7.4 follow-up).
49+
*
50+
* Exposed via @c stats_mysql_passthrough_auth_metrics. Each
51+
* counter increments at exactly one well-defined point in
52+
* @c handler_again___status_AUTHENTICATING_BACKEND_FOR_CLIENT
53+
* (see the corresponding @c bump_* methods below). Monotonic
54+
* since process start; reset only by process restart.
55+
*
56+
* Naming mirrors the existing @c stats_mysql_global pattern of
57+
* "what happened" snake-case-counters; no special suffixes.
58+
*/
59+
std::atomic<uint64_t> stat_probes_attempted;
60+
std::atomic<uint64_t> stat_probes_ok;
61+
std::atomic<uint64_t> stat_probes_failed_credentials;
62+
std::atomic<uint64_t> stat_probes_failed_transport;
63+
std::atomic<uint64_t> stat_lockouts_user;
64+
std::atomic<uint64_t> stat_lockouts_ip;
65+
std::atomic<uint64_t> stat_inflight_cap_rejects;
66+
std::atomic<uint64_t> stat_cache_hits;
67+
std::atomic<uint64_t> stat_cache_invalidations;
68+
// Sliding-window failure counters (spec §7.2). Per-username and
69+
// per-source-IP. Mutated only behind failure_lock — a separate
70+
// mutex from `lock` since these are write-mostly and accessed on
71+
// every probe.
72+
mutable pthread_mutex_t failure_lock;
73+
mutable std::unordered_map<std::string, std::deque<uint64_t>> failures_by_user;
74+
mutable std::unordered_map<std::string, std::deque<uint64_t>> failures_by_ip;
75+
76+
/**
77+
* @brief Compiled-regex cache for the username allowlist (spec §7.1).
78+
*
79+
* Compiling an re2::RE2 is cheap (single-digit microseconds) but
80+
* is paid every time a candidate connect is checked. Cache the
81+
* last-seen pattern string alongside its compiled form so that as
82+
* long as @c mysql-passthrough_auth_username_pattern is unchanged
83+
* we hit the compiled form. A pattern change (admin SET, reload)
84+
* triggers a re-compile under the write lock.
85+
*
86+
* @c pattern_lock is a pthread_rwlock so the COMMON case
87+
* (steady-state pattern, every probe takes the read lock for
88+
* FullMatch) doesn't serialize through a single mutex. The write
89+
* lock is taken only when the pattern STRING changes, which
90+
* happens on admin SET / LOAD MYSQL VARIABLES TO RUNTIME and is
91+
* effectively rare. re2::RE2::FullMatch is documented as
92+
* thread-safe on a const RE2 instance, so concurrent readers
93+
* are fine.
94+
*
95+
* Holds a raw pointer (forward-declared above) rather than
96+
* unique_ptr so we don't need to drag re2/re2.h into this header.
97+
*/
98+
mutable pthread_rwlock_t pattern_lock;
99+
mutable std::string compiled_pattern_str;
100+
mutable re2::RE2 *compiled_pattern;
101+
102+
public:
103+
MySQL_Passthrough_Auth_Cache();
104+
~MySQL_Passthrough_Auth_Cache();
105+
106+
// Look up a cached credential. Returns true on hit (and populates
107+
// out_cleartext); false on miss. If ttl_s > 0 and the entry is older
108+
// than ttl_s, the entry is evicted and a miss is returned.
109+
bool lookup(const std::string& username, std::string& out_cleartext, uint32_t ttl_s);
110+
111+
// Insert or replace a cached credential.
112+
void insert(const std::string& username, const std::string& cleartext, int hostgroup_probed);
113+
114+
// Evict a single entry. Returns true if the entry was present.
115+
bool evict(const std::string& username);
116+
117+
// Remove every entry.
118+
void clear();
119+
120+
// Number of entries currently held.
121+
size_t size() const;
122+
123+
// Snapshot of entries (without password) for stats / observability.
124+
std::vector<passthrough_entry_view> snapshot() const;
125+
126+
// Global in-flight probe counter (spec §7.3). Sessions wishing to
127+
// start a backend probe call try_acquire_inflight with the current
128+
// configured cap; on true they MUST pair with release_inflight when
129+
// the probe completes (success or failure). On false the session
130+
// must reject the auth with a generic ERR.
131+
bool try_acquire_inflight(int max_inflight);
132+
void release_inflight();
133+
int inflight() const;
134+
135+
// Sliding-window failure counters (spec §7.2). Sessions check
136+
// would_lockout before probing; on probe failure, record a
137+
// failure. window_s defines the sliding window in seconds; older
138+
// timestamps are dropped lazily on check.
139+
bool would_lockout_user(const std::string& username, int max_failures, uint32_t window_s) const;
140+
bool would_lockout_ip(const std::string& ip, int max_failures, uint32_t window_s) const;
141+
/**
142+
* @brief Record a probe failure against (username, ip) deques.
143+
*
144+
* @param max_keys Operator-tunable cap on the size of each
145+
* failure map (failures_by_user, failures_by_ip).
146+
* Driven by @c mysql-passthrough_auth_failure_map_cap.
147+
* When the map exceeds the cap, evict_oldest
148+
* reclaims an entry (defense-in-depth against
149+
* username/IP churn that would otherwise grow
150+
* the maps unbounded).
151+
*/
152+
void record_failure(const std::string& username, const std::string& ip, int max_keys);
153+
154+
/**
155+
* @brief Observability counters (B7 follow-up).
156+
*
157+
* Each @c bump_* method increments the corresponding atomic at
158+
* the single call site documented in @c MySQL_Session.cpp. The
159+
* @c metrics_snapshot helper returns the current values for the
160+
* @c stats_mysql_passthrough_auth_metrics virtual table.
161+
*/
162+
void bump_probes_attempted();
163+
void bump_probes_ok();
164+
void bump_probes_failed_credentials();
165+
void bump_probes_failed_transport();
166+
void bump_lockouts_user();
167+
void bump_lockouts_ip();
168+
void bump_inflight_cap_rejects();
169+
void bump_cache_hits();
170+
void bump_cache_invalidations();
171+
172+
/**
173+
* @brief Snapshot of metric counters + current-state gauges.
174+
*
175+
* Returns a vector of (name, value) pairs ordered for stable JSON /
176+
* stats-table output. Values are read with relaxed memory ordering
177+
* since stats are advisory, not synchronizing.
178+
*/
179+
struct metric_kv {
180+
std::string name;
181+
uint64_t value;
182+
};
183+
std::vector<metric_kv> metrics_snapshot() const;
184+
185+
/**
186+
* @brief Check whether @p username matches the configured allowlist
187+
* regex (spec §7.1, mysql-passthrough_auth_username_pattern).
188+
*
189+
* @param username Frontend user attempting pass-through.
190+
* @param pattern Regex string from the global variable. Empty means
191+
* "allow every username" (back-compat default).
192+
* @return @c true when the pattern is empty or @p username FullMatches
193+
* the compiled regex; @c false when the regex is set and
194+
* either fails to compile or the username doesn't match.
195+
*
196+
* The compiled regex is cached on the class behind @c pattern_lock;
197+
* a pattern-string change triggers a re-compile on the next call.
198+
* Match semantics are RE2 FullMatch (the entire username must
199+
* match), matching how query rules use re2 elsewhere in ProxySQL.
200+
* A regex that fails to compile is treated as a deny-all -- the
201+
* fail-safe direction for a security gate.
202+
*/
203+
bool username_allowed(const std::string& username, const std::string& pattern);
204+
205+
void print_version();
206+
};
207+
208+
#endif // PROXYSQL_MYSQL_PASSTHROUGH_AUTH_CACHE_H

include/MySQL_Protocol.h

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,16 @@ class MySQL_Protocol {
196196
void PPHR_6auth2(bool& ret, MyProt_tmp_auth_vars& vars1);
197197
bool PPHR_verify_sha2(MyProt_tmp_auth_vars& vars1, enum proxysql_auth_plugins passformat, PASSWORD_TYPE::E passtype);
198198
void PPHR_sha2full(bool& ret, MyProt_tmp_auth_vars& vars1, enum proxysql_auth_plugins passformat, PASSWORD_TYPE::E passtype);
199+
// Pass-through authentication (see doc/internal/passthrough_authentication.md).
200+
// PPHR_passthrough_init runs the protocol-side state machine for the
201+
// caching_sha2_password full-auth exchange when ProxySQL doesn't yet
202+
// have a password for the user. At switching_auth_stage==0 it sends
203+
// AuthMoreData{0x04} so the client emits its cleartext; at stage 5 it
204+
// stashes the captured cleartext on the data stream and transitions
205+
// the session to AUTHENTICATING_BACKEND_FOR_CLIENT so the backend
206+
// probe (handler_again___status_AUTHENTICATING_BACKEND_FOR_CLIENT)
207+
// can validate the credential.
208+
void PPHR_passthrough_init(MyProt_tmp_auth_vars& vars1);
199209
void PPHR_7auth1(bool& ret, MyProt_tmp_auth_vars& vars1, char * reply, account_details_t& attr1);
200210
void PPHR_7auth2(bool& ret, MyProt_tmp_auth_vars& vars1, char * reply, account_details_t& attr1);
201211
void PPHR_next_auth_stage(MyProt_tmp_auth_vars& vars1, PASSWORD_TYPE::E passtype);

0 commit comments

Comments
 (0)