Skip to main content

powdb_server/
handler.rs

1use crate::protocol::{Message, WireParam};
2use powdb_auth::{Permission, Role, UserStore};
3use powdb_query::executor::{is_read_only_statement, Engine};
4use powdb_query::parser;
5use powdb_query::result::{QueryError, QueryResult};
6use powdb_storage::types::Value;
7use std::collections::HashMap;
8use std::net::IpAddr;
9use std::sync::{Arc, Mutex, RwLock};
10use std::time::{Duration, Instant};
11use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt, BufReader, BufWriter};
12use tokio::sync::watch;
13use tracing::{debug, error, info, warn};
14use zeroize::Zeroizing;
15
16/// Tracks per-IP authentication failure counts for rate limiting.
17pub type AuthRateLimiter = Arc<Mutex<HashMap<IpAddr, (u32, Instant)>>>;
18
19/// Maximum query text length accepted from the wire (1 MB).
20const MAX_QUERY_LENGTH: usize = 1024 * 1024;
21
22/// Timeout for writing a response to the client. Prevents slow-drain
23/// clients from blocking the handler indefinitely.
24const WRITE_TIMEOUT: Duration = Duration::from_secs(30);
25
26/// Maximum number of auth failures per IP within the rate-limit window.
27const MAX_AUTH_FAILURES: u32 = 5;
28
29/// Window during which auth failures are counted (60 seconds).
30const AUTH_FAILURE_WINDOW: Duration = Duration::from_secs(60);
31
32/// Create a new shared rate limiter.
33pub fn new_rate_limiter() -> AuthRateLimiter {
34    Arc::new(Mutex::new(HashMap::new()))
35}
36
37/// Check whether an IP is rate-limited and record a failure if requested.
38/// Returns `true` if the IP should be rejected.
39fn is_rate_limited(limiter: &AuthRateLimiter, ip: IpAddr) -> bool {
40    let mut map = limiter.lock().unwrap_or_else(|e| e.into_inner());
41    // Clean up stale entries while we have the lock.
42    let now = Instant::now();
43    map.retain(|_, (_, ts)| now.duration_since(*ts) < AUTH_FAILURE_WINDOW);
44
45    if let Some((count, _)) = map.get(&ip) {
46        *count >= MAX_AUTH_FAILURES
47    } else {
48        false
49    }
50}
51
52/// Record an auth failure for the given IP.
53fn record_auth_failure(limiter: &AuthRateLimiter, ip: IpAddr) {
54    let mut map = limiter.lock().unwrap_or_else(|e| e.into_inner());
55    let now = Instant::now();
56    let entry = map.entry(ip).or_insert((0, now));
57    // Reset counter if the window has elapsed.
58    if now.duration_since(entry.1) >= AUTH_FAILURE_WINDOW {
59        *entry = (1, now);
60    } else {
61        entry.0 += 1;
62    }
63}
64
65/// Clear the failure counter on successful auth.
66fn clear_auth_failures(limiter: &AuthRateLimiter, ip: IpAddr) {
67    let mut map = limiter.lock().unwrap_or_else(|e| e.into_inner());
68    map.remove(&ip);
69}
70
71/// Constant-time password comparison. Hashes both inputs to fixed-size
72/// SHA-256 digests so neither length nor content leaks through timing.
73fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
74    use sha2::{Digest, Sha256};
75    let ha = Sha256::digest(a);
76    let hb = Sha256::digest(b);
77    let mut diff = 0u8;
78    for (x, y) in ha.iter().zip(hb.iter()) {
79        diff |= x ^ y;
80    }
81    diff == 0
82}
83
84/// An authenticated connection's identity. Bound at connect time and consulted
85/// on every query by [`dispatch_query`] to enforce the user's role: a
86/// `readonly` principal may only execute read statements.
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct Principal {
89    pub name: String,
90    pub role: String,
91}
92
93/// Whether `role` grants the `Write` permission. Unknown role names fail
94/// closed (no write). Shared-password / open / embedded modes never construct
95/// a [`Principal`], so they are unaffected by this gate.
96fn role_can_write(role: &str) -> bool {
97    Role::builtin(role).is_some_and(|r| r.allows(Permission::Write))
98}
99
100/// Enforce the principal's role against a parsed statement. Returns an error
101/// for any non-read statement (insert/update/delete/upsert/DDL/view ops/
102/// transaction control) when the role does not grant `Write`.
103///
104/// Classification uses the parsed AST via
105/// [`powdb_query::executor::is_read_only_statement`] — the exact same
106/// classifier the RwLock read/write split relies on — so the permission
107/// boundary and the concurrency boundary can never disagree.
108fn check_statement_permitted(
109    principal: Option<&Principal>,
110    stmt: &powdb_query::ast::Statement,
111) -> Result<(), QueryError> {
112    let Some(p) = principal else {
113        // No per-user identity (shared-password or open mode): full access,
114        // byte-identical to the pre-RBAC behavior.
115        return Ok(());
116    };
117    if is_read_only_statement(stmt) || role_can_write(&p.role) {
118        return Ok(());
119    }
120    Err(QueryError::Execution(format!(
121        "permission denied: role '{}' cannot execute write statements",
122        p.role
123    )))
124}
125
126/// Result of the connect-time authentication decision.
127#[derive(Debug, Clone, PartialEq, Eq)]
128pub enum AuthOutcome {
129    /// Authenticated. `principal` is `Some` when a named user authenticated via
130    /// the UserStore, and `None` for the legacy shared-password / open paths
131    /// where there is no per-user identity.
132    Authenticated { principal: Option<Principal> },
133    /// Rejected. The caller sends a generic "authentication failed" error and
134    /// records a rate-limit failure — it must not reveal which check failed.
135    Rejected,
136}
137
138/// Pure, exhaustively-testable authentication decision for a CONNECT handshake.
139///
140/// Policy:
141/// - If `users` has at least one user, multi-user auth is in force: a
142///   `username` is required and `users.authenticate(username, password)` must
143///   succeed. Unknown user, wrong password, or a missing username all reject
144///   with an indistinguishable `Rejected` (no user-vs-password leak).
145/// - If `users` is empty, fall back verbatim to the legacy behavior: when
146///   `expected_password` is `Some`, the candidate must match it (constant time);
147///   when `None`, no auth is required (open). The `username` is ignored here so
148///   that a new client talking to a shared-password server still connects.
149pub fn authenticate_connect(
150    users: &UserStore,
151    expected_password: Option<&str>,
152    username: Option<&str>,
153    password: Option<&str>,
154) -> AuthOutcome {
155    if !users.is_empty() {
156        // Multi-user mode: a username is mandatory.
157        let Some(name) = username else {
158            return AuthOutcome::Rejected;
159        };
160        let Some(candidate) = password else {
161            return AuthOutcome::Rejected;
162        };
163        match users.authenticate(name, candidate) {
164            Some(user) => AuthOutcome::Authenticated {
165                principal: Some(Principal {
166                    name: user.name.clone(),
167                    role: user.role.clone(),
168                }),
169            },
170            None => AuthOutcome::Rejected,
171        }
172    } else {
173        // Legacy shared-password fallback (byte-identical to prior behavior).
174        match expected_password {
175            Some(expected) => {
176                if password.is_some_and(|p| constant_time_eq(p.as_bytes(), expected.as_bytes())) {
177                    AuthOutcome::Authenticated { principal: None }
178                } else {
179                    AuthOutcome::Rejected
180                }
181            }
182            None => AuthOutcome::Authenticated { principal: None },
183        }
184    }
185}
186
187/// Error messages that are safe to forward to the client verbatim.
188const SAFE_ERROR_PREFIXES: &[&str] = &[
189    "table not found",
190    "column not found",
191    "parse error",
192    "type mismatch",
193    "unknown table",
194    "unknown column",
195    "unknown function",
196    "syntax error",
197    "expected",
198    "unexpected",
199    "missing",
200    "duplicate",
201    "invalid",
202    "cannot",
203    "no such",
204    "already exists",
205    "permission denied",
206    "row too large",
207    "unique constraint violation",
208];
209
210/// Sanitize an error message before sending it to the client.
211/// Known safe errors are passed through; everything else is replaced
212/// with a generic message to avoid leaking internal details.
213fn sanitize_error(e: &str) -> String {
214    let lower = e.to_lowercase();
215    for prefix in SAFE_ERROR_PREFIXES {
216        if lower.starts_with(prefix) {
217            return e.to_string();
218        }
219    }
220    "query execution error".into()
221}
222
223/// Write a message to the client with a timeout. Returns false if the
224/// write failed or timed out (caller should close the connection).
225async fn write_msg<W: AsyncWrite + Unpin>(writer: &mut BufWriter<W>, msg: &Message) -> bool {
226    let write_fut = async {
227        if msg.write_to(writer).await.is_err() {
228            return false;
229        }
230        writer.flush().await.is_ok()
231    };
232    tokio::time::timeout(WRITE_TIMEOUT, write_fut)
233        .await
234        .unwrap_or_default()
235}
236
237/// Options for a single connection, bundled to keep `handle_connection`'s
238/// argument list short.
239pub struct ConnOpts<'a> {
240    pub engine: Arc<RwLock<Engine>>,
241    /// Expected client password. Wrapped in `Zeroizing` so the secret is wiped
242    /// from memory on drop (defends against leaking via a core dump).
243    pub expected_password: Option<Zeroizing<String>>,
244    /// Multi-user store loaded from the data dir at startup. When it has users,
245    /// the handshake authenticates `(username, password)` against it; when empty
246    /// the server falls back to `expected_password`. Shared across connections.
247    pub users: Arc<UserStore>,
248    pub shutdown_rx: &'a mut watch::Receiver<bool>,
249    pub idle_timeout: Duration,
250    pub query_timeout: Duration,
251    pub rate_limiter: Option<&'a AuthRateLimiter>,
252    pub peer_addr: Option<std::net::SocketAddr>,
253}
254
255/// Execute a query against the engine under the RwLock. Read-only
256/// statements acquire `.read()` so concurrent SELECTs can scan in
257/// parallel; mutations acquire `.write()`.
258///
259/// When `principal` is `Some`, the user's role is enforced first: a role
260/// without the `Write` permission (i.e. `readonly`) gets a clean
261/// "permission denied" error for any non-read statement, before any lock
262/// is taken or any engine state is touched.
263fn dispatch_query(
264    engine: &Arc<RwLock<Engine>>,
265    query: &str,
266    principal: Option<&Principal>,
267) -> Result<QueryResult, QueryError> {
268    let stmt_result = parser::parse(query).map_err(|e| e.to_string());
269
270    // Role enforcement happens on the parsed AST. Statements that fail to
271    // parse fall through — the engine returns the parse error itself and
272    // can never execute anything for them.
273    if let Ok(stmt) = &stmt_result {
274        check_statement_permitted(principal, stmt)?;
275    }
276
277    let can_try_read = matches!(&stmt_result, Ok(s) if is_read_only_statement(s));
278    if can_try_read {
279        let res = {
280            let eng = engine
281                .read()
282                .map_err(|e| QueryError::Execution(format!("lock poisoned: {e}")))?;
283            eng.execute_powql_readonly(query)
284        };
285        match res {
286            Ok(r) => return Ok(r),
287            Err(QueryError::ReadonlyNeedsWrite) => {
288                // Escalate: fall through to the write path below.
289            }
290            Err(e) => return Err(e),
291        }
292    }
293
294    let mut eng = engine
295        .write()
296        .map_err(|e| QueryError::Execution(format!("lock poisoned: {e}")))?;
297    eng.execute_powql(query)
298}
299
300/// Convert a wire parameter into the query-crate [`ParamValue`] used for
301/// token-level binding.
302fn wire_param_to_value(p: &WireParam) -> powdb_query::ast::ParamValue {
303    use powdb_query::ast::ParamValue;
304    match p {
305        WireParam::Null => ParamValue::Null,
306        WireParam::Int(v) => ParamValue::Int(*v),
307        WireParam::Float(v) => ParamValue::Float(*v),
308        WireParam::Bool(v) => ParamValue::Bool(*v),
309        WireParam::Str(s) => ParamValue::Str(s.clone()),
310    }
311}
312
313/// Parameterized counterpart of [`dispatch_query`]. Routes through the exact
314/// same role-enforcement and read/write escalation logic, but binds the
315/// `$N` placeholders at the token level via the query crate's
316/// `parse_with_params` path. A string parameter can never change the query's
317/// shape — it is substituted as a literal token, not interpolated text.
318fn dispatch_query_with_params(
319    engine: &Arc<RwLock<Engine>>,
320    query: &str,
321    params: &[WireParam],
322    principal: Option<&Principal>,
323) -> Result<QueryResult, QueryError> {
324    let bound: Vec<powdb_query::ast::ParamValue> = params.iter().map(wire_param_to_value).collect();
325
326    // Parse once (with params bound) so role enforcement and read/write
327    // classification see exactly the statement that will execute.
328    let stmt_result = parser::parse_with_params(query, &bound).map_err(|e| e.to_string());
329
330    if let Ok(stmt) = &stmt_result {
331        check_statement_permitted(principal, stmt)?;
332    }
333
334    let can_try_read = matches!(&stmt_result, Ok(s) if is_read_only_statement(s));
335    if can_try_read {
336        let res = {
337            let eng = engine
338                .read()
339                .map_err(|e| QueryError::Execution(format!("lock poisoned: {e}")))?;
340            eng.execute_powql_readonly_with_params(query, &bound)
341        };
342        match res {
343            Ok(r) => return Ok(r),
344            Err(QueryError::ReadonlyNeedsWrite) => {
345                // Escalate to the write path below.
346            }
347            Err(e) => return Err(e),
348        }
349    }
350
351    let mut eng = engine
352        .write()
353        .map_err(|e| QueryError::Execution(format!("lock poisoned: {e}")))?;
354    eng.execute_powql_with_params(query, &bound)
355}
356
357pub async fn handle_connection<S>(stream: S, opts: ConnOpts<'_>)
358where
359    S: AsyncRead + AsyncWrite + Unpin,
360{
361    let ConnOpts {
362        engine,
363        expected_password,
364        users,
365        shutdown_rx,
366        idle_timeout,
367        query_timeout,
368        rate_limiter,
369        peer_addr,
370    } = opts;
371
372    let peer = peer_addr
373        .map(|a| a.to_string())
374        .unwrap_or_else(|| "unknown".into());
375    let peer_ip = peer_addr.map(|a| a.ip());
376
377    let (reader, writer) = tokio::io::split(stream);
378    let mut reader = BufReader::new(reader);
379    let mut writer = BufWriter::new(writer);
380
381    // Wait for Connect message (with idle timeout).
382    // Accept Ping messages before authentication so load balancers can
383    // health-check without completing a full CONNECT handshake.
384    // Uses the smaller pre-auth payload limit (4 KB) to prevent memory abuse.
385    let connect_msg = loop {
386        match tokio::time::timeout(idle_timeout, Message::read_from_preauth(&mut reader)).await {
387            Ok(Ok(Some(Message::Ping))) => {
388                debug!(peer = %peer, "pre-auth ping");
389                if !write_msg(&mut writer, &Message::Pong).await {
390                    return;
391                }
392                continue;
393            }
394            Ok(Ok(Some(msg))) => break msg,
395            Ok(Ok(None)) => {
396                debug!(peer = %peer, "client closed before CONNECT");
397                return;
398            }
399            Ok(Err(e)) => {
400                error!(peer = %peer, error = %e, "error reading CONNECT");
401                return;
402            }
403            Err(_) => {
404                warn!(peer = %peer, "idle timeout waiting for CONNECT");
405                return;
406            }
407        }
408    };
409
410    // The authenticated identity for this connection. Bound at connect time
411    // and enforced on every query by `dispatch_query`.
412    let principal: Option<Principal>;
413    match connect_msg {
414        Message::Connect {
415            db_name,
416            password,
417            username,
418        } => {
419            // Check rate limiting before verifying credentials.
420            if let (Some(limiter), Some(ip)) = (rate_limiter, peer_ip) {
421                if is_rate_limited(limiter, ip) {
422                    warn!(peer = %peer, "rate limited: too many auth failures");
423                    let err = Message::Error {
424                        message: "too many auth failures, try again later".into(),
425                    };
426                    write_msg(&mut writer, &err).await;
427                    return;
428                }
429            }
430
431            let outcome = authenticate_connect(
432                &users,
433                expected_password.as_ref().map(|p| p.as_str()),
434                username.as_deref(),
435                password.as_ref().map(|p| p.as_str()),
436            );
437
438            match outcome {
439                AuthOutcome::Rejected => {
440                    warn!(peer = %peer, db = %db_name, "auth rejected");
441                    // Record the failure for rate limiting.
442                    if let (Some(limiter), Some(ip)) = (rate_limiter, peer_ip) {
443                        record_auth_failure(limiter, ip);
444                    }
445                    let err = Message::Error {
446                        message: "authentication failed".into(),
447                    };
448                    write_msg(&mut writer, &err).await;
449                    return;
450                }
451                AuthOutcome::Authenticated {
452                    principal: auth_principal,
453                } => {
454                    // Auth succeeded — clear any prior failure count.
455                    if let (Some(limiter), Some(ip)) = (rate_limiter, peer_ip) {
456                        clear_auth_failures(limiter, ip);
457                    }
458                    match &auth_principal {
459                        Some(p) => {
460                            info!(peer = %peer, db = %db_name, user = %p.name, role = %p.role, "authenticated");
461                        }
462                        None => {
463                            info!(peer = %peer, db = %db_name, "client connected");
464                        }
465                    }
466                    principal = auth_principal;
467                }
468            }
469
470            let ok = Message::ConnectOk {
471                version: env!("CARGO_PKG_VERSION").into(),
472            };
473            if !write_msg(&mut writer, &ok).await {
474                return;
475            }
476        }
477        _ => {
478            warn!(peer = %peer, "first message was not CONNECT");
479            let err = Message::Error {
480                message: "expected CONNECT".into(),
481            };
482            write_msg(&mut writer, &err).await;
483            return;
484        }
485    }
486
487    // Main query loop with idle timeout and shutdown awareness.
488    loop {
489        let msg = tokio::select! {
490            // Read next message with idle timeout.
491            result = tokio::time::timeout(idle_timeout, Message::read_from(&mut reader)) => {
492                match result {
493                    Ok(Ok(Some(msg))) => msg,
494                    Ok(Ok(None)) => break,
495                    Ok(Err(e)) => {
496                        error!(peer = %peer, error = %e, "read error");
497                        break;
498                    }
499                    Err(_) => {
500                        info!(peer = %peer, "idle timeout, closing connection");
501                        let err = Message::Error { message: "idle timeout".into() };
502                        write_msg(&mut writer, &err).await;
503                        break;
504                    }
505                }
506            }
507            // If server is shutting down, notify client and close.
508            _ = shutdown_rx.changed() => {
509                if *shutdown_rx.borrow() {
510                    info!(peer = %peer, "server shutting down, closing connection");
511                    let err = Message::Error { message: "server shutting down".into() };
512                    write_msg(&mut writer, &err).await;
513                    break;
514                }
515                continue;
516            }
517        };
518
519        let response = match msg {
520            Message::Ping => {
521                debug!(peer = %peer, "ping");
522                Message::Pong
523            }
524            Message::Query { query } => {
525                if query.len() > MAX_QUERY_LENGTH {
526                    Message::Error {
527                        message: format!(
528                            "query too large: {} bytes (max {})",
529                            query.len(),
530                            MAX_QUERY_LENGTH
531                        ),
532                    }
533                } else {
534                    debug!(peer = %peer, query = %query, "received query");
535                    let handle = tokio::task::spawn_blocking({
536                        let engine = engine.clone();
537                        let query = query.clone();
538                        let principal = principal.clone();
539                        move || dispatch_query(&engine, &query, principal.as_ref())
540                    });
541                    let abort_handle = handle.abort_handle();
542                    match tokio::time::timeout(query_timeout, handle).await {
543                        Ok(Ok(Ok(result))) => query_result_to_message(result),
544                        Ok(Ok(Err(e))) => Message::Error {
545                            message: sanitize_error(&e.to_string()),
546                        },
547                        Ok(Err(e)) => Message::Error {
548                            message: format!("internal error: {e}"),
549                        },
550                        Err(_) => {
551                            abort_handle.abort();
552                            warn!(peer = %peer, query = %query, "query timeout exceeded");
553                            Message::Error {
554                                message: "query timeout exceeded".into(),
555                            }
556                        }
557                    }
558                }
559            }
560            Message::QueryWithParams { query, params } => {
561                if query.len() > MAX_QUERY_LENGTH {
562                    Message::Error {
563                        message: format!(
564                            "query too large: {} bytes (max {})",
565                            query.len(),
566                            MAX_QUERY_LENGTH
567                        ),
568                    }
569                } else {
570                    debug!(peer = %peer, query = %query, n_params = params.len(), "received parameterized query");
571                    let handle = tokio::task::spawn_blocking({
572                        let engine = engine.clone();
573                        let query = query.clone();
574                        let params = params.clone();
575                        let principal = principal.clone();
576                        move || {
577                            dispatch_query_with_params(&engine, &query, &params, principal.as_ref())
578                        }
579                    });
580                    let abort_handle = handle.abort_handle();
581                    match tokio::time::timeout(query_timeout, handle).await {
582                        Ok(Ok(Ok(result))) => query_result_to_message(result),
583                        Ok(Ok(Err(e))) => Message::Error {
584                            message: sanitize_error(&e.to_string()),
585                        },
586                        Ok(Err(e)) => Message::Error {
587                            message: format!("internal error: {e}"),
588                        },
589                        Err(_) => {
590                            abort_handle.abort();
591                            warn!(peer = %peer, query = %query, "query timeout exceeded");
592                            Message::Error {
593                                message: "query timeout exceeded".into(),
594                            }
595                        }
596                    }
597                }
598            }
599            Message::Disconnect => {
600                debug!(peer = %peer, "received DISCONNECT");
601                break;
602            }
603            _ => Message::Error {
604                message: "unexpected message type".into(),
605            },
606        };
607
608        if !write_msg(&mut writer, &response).await {
609            break;
610        }
611    }
612
613    info!(peer = %peer, "client disconnected");
614}
615
616fn query_result_to_message(result: QueryResult) -> Message {
617    match result {
618        QueryResult::Rows { columns, rows } => {
619            let str_rows: Vec<Vec<String>> = rows
620                .iter()
621                .map(|row| row.iter().map(value_to_display).collect())
622                .collect();
623            Message::ResultRows {
624                columns,
625                rows: str_rows,
626            }
627        }
628        QueryResult::Scalar(val) => Message::ResultScalar {
629            value: value_to_display(&val),
630        },
631        QueryResult::Modified(n) => Message::ResultOk { affected: n },
632        QueryResult::Created(name) => Message::ResultMessage {
633            message: format!("type {name} created"),
634        },
635        QueryResult::Executed { message } => Message::ResultMessage { message },
636    }
637}
638
639fn value_to_display(v: &Value) -> String {
640    match v {
641        Value::Int(n)      => n.to_string(),
642        Value::Float(n)    => format!("{n}"),
643        Value::Bool(b)     => b.to_string(),
644        Value::Str(s)      => s.clone(),
645        Value::DateTime(t) => format!("{t}"),
646        Value::Uuid(u)     => format!("{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
647            u[0], u[1], u[2], u[3], u[4], u[5], u[6], u[7],
648            u[8], u[9], u[10], u[11], u[12], u[13], u[14], u[15]),
649        Value::Bytes(b)    => format!("<{} bytes>", b.len()),
650        // NULL is serialized as the bareword "null" on the wire. This is the
651        // sentinel the TypeScript client's typed-row decoder already
652        // documents and matches (`coerceValue` treats the exact token
653        // "null" as NULL for non-str columns); the previous "{}" rendering
654        // was a bug that neither the TS client nor the CLI recognized.
655        Value::Empty       => "null".into(),
656    }
657}
658
659#[cfg(test)]
660mod tests {
661    use super::*;
662
663    // ---- Wire NULL rendering (Fix: remote protocol rendered NULL as `{}`) ----
664
665    #[test]
666    fn null_serializes_as_null_bareword_on_wire() {
667        assert_eq!(value_to_display(&Value::Empty), "null");
668    }
669
670    // ---- Error sanitization allowlist ----
671
672    #[test]
673    fn unique_violation_error_surfaces_to_remote_clients() {
674        // The storage layer reports the actionable message; the server must
675        // not replace it with the generic "query execution error".
676        assert_eq!(
677            sanitize_error("unique constraint violation on User.email"),
678            "unique constraint violation on User.email"
679        );
680    }
681
682    #[test]
683    fn internal_errors_stay_generic() {
684        assert_eq!(
685            sanitize_error("some internal io panic detail"),
686            "query execution error"
687        );
688    }
689
690    // ---- Role enforcement (Fix: readonly role was not enforced) ----
691
692    fn parsed(q: &str) -> powdb_query::ast::Statement {
693        parser::parse(q).unwrap()
694    }
695
696    fn principal(role: &str) -> Option<Principal> {
697        Some(Principal {
698            name: "u".into(),
699            role: role.into(),
700        })
701    }
702
703    #[test]
704    fn readonly_can_read_but_not_write() {
705        let p = principal("readonly");
706        // Reads pass.
707        assert!(check_statement_permitted(p.as_ref(), &parsed("User")).is_ok());
708        assert!(check_statement_permitted(p.as_ref(), &parsed("count(User)")).is_ok());
709        assert!(check_statement_permitted(p.as_ref(), &parsed("explain User")).is_ok());
710        // Writes, DDL, and transaction control are denied.
711        for q in [
712            r#"insert User { name := "x" }"#,
713            "User filter .id = 1 update { age := 2 }",
714            "User filter .id = 1 delete",
715            "drop User",
716            "alter User add column c: str",
717            "type T { required id: int }",
718            "begin",
719            "commit",
720            "rollback",
721        ] {
722            let err = check_statement_permitted(p.as_ref(), &parsed(q))
723                .expect_err(&format!("must deny: {q}"));
724            assert!(
725                err.to_string().contains("permission denied"),
726                "unexpected error for {q}: {err}"
727            );
728        }
729    }
730
731    #[test]
732    fn readwrite_and_admin_have_full_query_access() {
733        for role in ["readwrite", "admin"] {
734            let p = principal(role);
735            assert!(check_statement_permitted(p.as_ref(), &parsed("User")).is_ok());
736            assert!(check_statement_permitted(
737                p.as_ref(),
738                &parsed(r#"insert User { name := "x" }"#)
739            )
740            .is_ok());
741            assert!(check_statement_permitted(p.as_ref(), &parsed("drop User")).is_ok());
742        }
743    }
744
745    #[test]
746    fn unknown_role_fails_closed_for_writes() {
747        let p = principal("mystery");
748        assert!(check_statement_permitted(p.as_ref(), &parsed("User")).is_ok());
749        assert!(
750            check_statement_permitted(p.as_ref(), &parsed(r#"insert User { name := "x" }"#))
751                .is_err()
752        );
753    }
754
755    #[test]
756    fn no_principal_means_full_access() {
757        // Shared-password / open mode: no per-user identity, no restriction.
758        assert!(check_statement_permitted(None, &parsed("drop User")).is_ok());
759        assert!(check_statement_permitted(None, &parsed(r#"insert User { name := "x" }"#)).is_ok());
760    }
761
762    fn store_with_alice() -> UserStore {
763        let mut s = UserStore::new();
764        s.create_user("alice", "pw", "readwrite").unwrap();
765        s
766    }
767
768    // ---- Empty store: legacy shared-password fallback ----
769
770    #[test]
771    fn empty_store_no_password_is_open() {
772        let s = UserStore::new();
773        assert_eq!(
774            authenticate_connect(&s, None, None, None),
775            AuthOutcome::Authenticated { principal: None }
776        );
777        // Even a stray username/password is accepted (legacy open behavior).
778        assert_eq!(
779            authenticate_connect(&s, None, Some("x"), Some("y")),
780            AuthOutcome::Authenticated { principal: None }
781        );
782    }
783
784    #[test]
785    fn empty_store_correct_shared_password_succeeds() {
786        let s = UserStore::new();
787        assert_eq!(
788            authenticate_connect(&s, Some("pw"), None, Some("pw")),
789            AuthOutcome::Authenticated { principal: None }
790        );
791    }
792
793    #[test]
794    fn empty_store_wrong_shared_password_rejected() {
795        let s = UserStore::new();
796        assert_eq!(
797            authenticate_connect(&s, Some("pw"), None, Some("bad")),
798            AuthOutcome::Rejected
799        );
800    }
801
802    #[test]
803    fn empty_store_missing_password_rejected_when_expected() {
804        let s = UserStore::new();
805        assert_eq!(
806            authenticate_connect(&s, Some("pw"), None, None),
807            AuthOutcome::Rejected
808        );
809    }
810
811    #[test]
812    fn empty_store_ignores_username_for_shared_password() {
813        // A new client may send a username even against a shared-password
814        // server; the username is ignored and the password still governs.
815        let s = UserStore::new();
816        assert_eq!(
817            authenticate_connect(&s, Some("pw"), Some("whoever"), Some("pw")),
818            AuthOutcome::Authenticated { principal: None }
819        );
820    }
821
822    // ---- Populated store: multi-user auth ----
823
824    #[test]
825    fn user_auth_success_binds_principal() {
826        let s = store_with_alice();
827        assert_eq!(
828            authenticate_connect(&s, None, Some("alice"), Some("pw")),
829            AuthOutcome::Authenticated {
830                principal: Some(Principal {
831                    name: "alice".into(),
832                    role: "readwrite".into(),
833                })
834            }
835        );
836    }
837
838    #[test]
839    fn user_auth_wrong_password_rejected() {
840        let s = store_with_alice();
841        assert_eq!(
842            authenticate_connect(&s, None, Some("alice"), Some("bad")),
843            AuthOutcome::Rejected
844        );
845    }
846
847    #[test]
848    fn user_auth_unknown_user_rejected() {
849        let s = store_with_alice();
850        assert_eq!(
851            authenticate_connect(&s, None, Some("mallory"), Some("pw")),
852            AuthOutcome::Rejected
853        );
854    }
855
856    #[test]
857    fn user_auth_missing_username_rejected() {
858        let s = store_with_alice();
859        assert_eq!(
860            authenticate_connect(&s, None, None, Some("pw")),
861            AuthOutcome::Rejected
862        );
863    }
864
865    #[test]
866    fn user_auth_missing_password_rejected() {
867        let s = store_with_alice();
868        assert_eq!(
869            authenticate_connect(&s, Some("pw"), Some("alice"), None),
870            AuthOutcome::Rejected
871        );
872    }
873
874    #[test]
875    fn user_auth_ignores_shared_password_when_users_present() {
876        // With users present, the shared password is irrelevant: supplying it as
877        // the password without a valid user must NOT authenticate.
878        let s = store_with_alice();
879        assert_eq!(
880            authenticate_connect(&s, Some("shared"), None, Some("shared")),
881            AuthOutcome::Rejected
882        );
883    }
884}