use crate::protocol::{Message, WireParam};
use powdb_auth::{Permission, Role, UserStore};
use powdb_query::executor::{is_read_only_statement, Engine};
use powdb_query::parser;
use powdb_query::result::{QueryError, QueryResult};
use powdb_storage::types::Value;
use std::collections::HashMap;
use std::net::IpAddr;
use std::sync::{Arc, Mutex, RwLock};
use std::time::{Duration, Instant};
use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt, BufReader, BufWriter};
use tokio::sync::watch;
use tracing::{debug, error, info, warn};
use zeroize::Zeroizing;
pub type AuthRateLimiter = Arc<Mutex<HashMap<IpAddr, (u32, Instant)>>>;
const MAX_QUERY_LENGTH: usize = 1024 * 1024;
const WRITE_TIMEOUT: Duration = Duration::from_secs(30);
const MAX_AUTH_FAILURES: u32 = 5;
const AUTH_FAILURE_WINDOW: Duration = Duration::from_secs(60);
pub fn new_rate_limiter() -> AuthRateLimiter {
Arc::new(Mutex::new(HashMap::new()))
}
fn is_rate_limited(limiter: &AuthRateLimiter, ip: IpAddr) -> bool {
let mut map = limiter.lock().unwrap_or_else(|e| e.into_inner());
let now = Instant::now();
map.retain(|_, (_, ts)| now.duration_since(*ts) < AUTH_FAILURE_WINDOW);
if let Some((count, _)) = map.get(&ip) {
*count >= MAX_AUTH_FAILURES
} else {
false
}
}
fn record_auth_failure(limiter: &AuthRateLimiter, ip: IpAddr) {
let mut map = limiter.lock().unwrap_or_else(|e| e.into_inner());
let now = Instant::now();
let entry = map.entry(ip).or_insert((0, now));
if now.duration_since(entry.1) >= AUTH_FAILURE_WINDOW {
*entry = (1, now);
} else {
entry.0 += 1;
}
}
fn clear_auth_failures(limiter: &AuthRateLimiter, ip: IpAddr) {
let mut map = limiter.lock().unwrap_or_else(|e| e.into_inner());
map.remove(&ip);
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
use sha2::{Digest, Sha256};
let ha = Sha256::digest(a);
let hb = Sha256::digest(b);
let mut diff = 0u8;
for (x, y) in ha.iter().zip(hb.iter()) {
diff |= x ^ y;
}
diff == 0
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Principal {
pub name: String,
pub role: String,
}
fn role_can_write(role: &str) -> bool {
Role::builtin(role).is_some_and(|r| r.allows(Permission::Write))
}
fn check_statement_permitted(
principal: Option<&Principal>,
stmt: &powdb_query::ast::Statement,
) -> Result<(), QueryError> {
let Some(p) = principal else {
return Ok(());
};
if is_read_only_statement(stmt) || role_can_write(&p.role) {
return Ok(());
}
Err(QueryError::Execution(format!(
"permission denied: role '{}' cannot execute write statements",
p.role
)))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuthOutcome {
Authenticated { principal: Option<Principal> },
Rejected,
}
pub fn authenticate_connect(
users: &UserStore,
expected_password: Option<&str>,
username: Option<&str>,
password: Option<&str>,
) -> AuthOutcome {
if !users.is_empty() {
let Some(name) = username else {
return AuthOutcome::Rejected;
};
let Some(candidate) = password else {
return AuthOutcome::Rejected;
};
match users.authenticate(name, candidate) {
Some(user) => AuthOutcome::Authenticated {
principal: Some(Principal {
name: user.name.clone(),
role: user.role.clone(),
}),
},
None => AuthOutcome::Rejected,
}
} else {
match expected_password {
Some(expected) => {
if password.is_some_and(|p| constant_time_eq(p.as_bytes(), expected.as_bytes())) {
AuthOutcome::Authenticated { principal: None }
} else {
AuthOutcome::Rejected
}
}
None => AuthOutcome::Authenticated { principal: None },
}
}
}
const SAFE_ERROR_PREFIXES: &[&str] = &[
"table not found",
"column not found",
"parse error",
"type mismatch",
"unknown table",
"unknown column",
"unknown function",
"syntax error",
"expected",
"unexpected",
"missing",
"duplicate",
"invalid",
"cannot",
"no such",
"already exists",
"permission denied",
"row too large",
"unique constraint violation",
];
fn sanitize_error(e: &str) -> String {
let lower = e.to_lowercase();
for prefix in SAFE_ERROR_PREFIXES {
if lower.starts_with(prefix) {
return e.to_string();
}
}
"query execution error".into()
}
async fn write_msg<W: AsyncWrite + Unpin>(writer: &mut BufWriter<W>, msg: &Message) -> bool {
let write_fut = async {
if msg.write_to(writer).await.is_err() {
return false;
}
writer.flush().await.is_ok()
};
tokio::time::timeout(WRITE_TIMEOUT, write_fut)
.await
.unwrap_or_default()
}
pub struct ConnOpts<'a> {
pub engine: Arc<RwLock<Engine>>,
pub expected_password: Option<Zeroizing<String>>,
pub users: Arc<UserStore>,
pub shutdown_rx: &'a mut watch::Receiver<bool>,
pub idle_timeout: Duration,
pub query_timeout: Duration,
pub rate_limiter: Option<&'a AuthRateLimiter>,
pub peer_addr: Option<std::net::SocketAddr>,
}
fn dispatch_query(
engine: &Arc<RwLock<Engine>>,
query: &str,
principal: Option<&Principal>,
) -> Result<QueryResult, QueryError> {
let stmt_result = parser::parse(query).map_err(|e| e.to_string());
if let Ok(stmt) = &stmt_result {
check_statement_permitted(principal, stmt)?;
}
let can_try_read = matches!(&stmt_result, Ok(s) if is_read_only_statement(s));
if can_try_read {
let res = {
let eng = engine
.read()
.map_err(|e| QueryError::Execution(format!("lock poisoned: {e}")))?;
eng.execute_powql_readonly(query)
};
match res {
Ok(r) => return Ok(r),
Err(QueryError::ReadonlyNeedsWrite) => {
}
Err(e) => return Err(e),
}
}
let mut eng = engine
.write()
.map_err(|e| QueryError::Execution(format!("lock poisoned: {e}")))?;
eng.execute_powql(query)
}
fn wire_param_to_value(p: &WireParam) -> powdb_query::ast::ParamValue {
use powdb_query::ast::ParamValue;
match p {
WireParam::Null => ParamValue::Null,
WireParam::Int(v) => ParamValue::Int(*v),
WireParam::Float(v) => ParamValue::Float(*v),
WireParam::Bool(v) => ParamValue::Bool(*v),
WireParam::Str(s) => ParamValue::Str(s.clone()),
}
}
fn dispatch_query_with_params(
engine: &Arc<RwLock<Engine>>,
query: &str,
params: &[WireParam],
principal: Option<&Principal>,
) -> Result<QueryResult, QueryError> {
let bound: Vec<powdb_query::ast::ParamValue> = params.iter().map(wire_param_to_value).collect();
let stmt_result = parser::parse_with_params(query, &bound).map_err(|e| e.to_string());
if let Ok(stmt) = &stmt_result {
check_statement_permitted(principal, stmt)?;
}
let can_try_read = matches!(&stmt_result, Ok(s) if is_read_only_statement(s));
if can_try_read {
let res = {
let eng = engine
.read()
.map_err(|e| QueryError::Execution(format!("lock poisoned: {e}")))?;
eng.execute_powql_readonly_with_params(query, &bound)
};
match res {
Ok(r) => return Ok(r),
Err(QueryError::ReadonlyNeedsWrite) => {
}
Err(e) => return Err(e),
}
}
let mut eng = engine
.write()
.map_err(|e| QueryError::Execution(format!("lock poisoned: {e}")))?;
eng.execute_powql_with_params(query, &bound)
}
pub async fn handle_connection<S>(stream: S, opts: ConnOpts<'_>)
where
S: AsyncRead + AsyncWrite + Unpin,
{
let ConnOpts {
engine,
expected_password,
users,
shutdown_rx,
idle_timeout,
query_timeout,
rate_limiter,
peer_addr,
} = opts;
let peer = peer_addr
.map(|a| a.to_string())
.unwrap_or_else(|| "unknown".into());
let peer_ip = peer_addr.map(|a| a.ip());
let (reader, writer) = tokio::io::split(stream);
let mut reader = BufReader::new(reader);
let mut writer = BufWriter::new(writer);
let connect_msg = loop {
match tokio::time::timeout(idle_timeout, Message::read_from_preauth(&mut reader)).await {
Ok(Ok(Some(Message::Ping))) => {
debug!(peer = %peer, "pre-auth ping");
if !write_msg(&mut writer, &Message::Pong).await {
return;
}
continue;
}
Ok(Ok(Some(msg))) => break msg,
Ok(Ok(None)) => {
debug!(peer = %peer, "client closed before CONNECT");
return;
}
Ok(Err(e)) => {
error!(peer = %peer, error = %e, "error reading CONNECT");
return;
}
Err(_) => {
warn!(peer = %peer, "idle timeout waiting for CONNECT");
return;
}
}
};
let principal: Option<Principal>;
match connect_msg {
Message::Connect {
db_name,
password,
username,
} => {
if let (Some(limiter), Some(ip)) = (rate_limiter, peer_ip) {
if is_rate_limited(limiter, ip) {
warn!(peer = %peer, "rate limited: too many auth failures");
let err = Message::Error {
message: "too many auth failures, try again later".into(),
};
write_msg(&mut writer, &err).await;
return;
}
}
let outcome = authenticate_connect(
&users,
expected_password.as_ref().map(|p| p.as_str()),
username.as_deref(),
password.as_ref().map(|p| p.as_str()),
);
match outcome {
AuthOutcome::Rejected => {
warn!(peer = %peer, db = %db_name, "auth rejected");
if let (Some(limiter), Some(ip)) = (rate_limiter, peer_ip) {
record_auth_failure(limiter, ip);
}
let err = Message::Error {
message: "authentication failed".into(),
};
write_msg(&mut writer, &err).await;
return;
}
AuthOutcome::Authenticated {
principal: auth_principal,
} => {
if let (Some(limiter), Some(ip)) = (rate_limiter, peer_ip) {
clear_auth_failures(limiter, ip);
}
match &auth_principal {
Some(p) => {
info!(peer = %peer, db = %db_name, user = %p.name, role = %p.role, "authenticated");
}
None => {
info!(peer = %peer, db = %db_name, "client connected");
}
}
principal = auth_principal;
}
}
let ok = Message::ConnectOk {
version: env!("CARGO_PKG_VERSION").into(),
};
if !write_msg(&mut writer, &ok).await {
return;
}
}
_ => {
warn!(peer = %peer, "first message was not CONNECT");
let err = Message::Error {
message: "expected CONNECT".into(),
};
write_msg(&mut writer, &err).await;
return;
}
}
loop {
let msg = tokio::select! {
result = tokio::time::timeout(idle_timeout, Message::read_from(&mut reader)) => {
match result {
Ok(Ok(Some(msg))) => msg,
Ok(Ok(None)) => break,
Ok(Err(e)) => {
error!(peer = %peer, error = %e, "read error");
break;
}
Err(_) => {
info!(peer = %peer, "idle timeout, closing connection");
let err = Message::Error { message: "idle timeout".into() };
write_msg(&mut writer, &err).await;
break;
}
}
}
_ = shutdown_rx.changed() => {
if *shutdown_rx.borrow() {
info!(peer = %peer, "server shutting down, closing connection");
let err = Message::Error { message: "server shutting down".into() };
write_msg(&mut writer, &err).await;
break;
}
continue;
}
};
let response = match msg {
Message::Ping => {
debug!(peer = %peer, "ping");
Message::Pong
}
Message::Query { query } => {
if query.len() > MAX_QUERY_LENGTH {
Message::Error {
message: format!(
"query too large: {} bytes (max {})",
query.len(),
MAX_QUERY_LENGTH
),
}
} else {
debug!(peer = %peer, query = %query, "received query");
let handle = tokio::task::spawn_blocking({
let engine = engine.clone();
let query = query.clone();
let principal = principal.clone();
move || dispatch_query(&engine, &query, principal.as_ref())
});
let abort_handle = handle.abort_handle();
match tokio::time::timeout(query_timeout, handle).await {
Ok(Ok(Ok(result))) => query_result_to_message(result),
Ok(Ok(Err(e))) => Message::Error {
message: sanitize_error(&e.to_string()),
},
Ok(Err(e)) => Message::Error {
message: format!("internal error: {e}"),
},
Err(_) => {
abort_handle.abort();
warn!(peer = %peer, query = %query, "query timeout exceeded");
Message::Error {
message: "query timeout exceeded".into(),
}
}
}
}
}
Message::QueryWithParams { query, params } => {
if query.len() > MAX_QUERY_LENGTH {
Message::Error {
message: format!(
"query too large: {} bytes (max {})",
query.len(),
MAX_QUERY_LENGTH
),
}
} else {
debug!(peer = %peer, query = %query, n_params = params.len(), "received parameterized query");
let handle = tokio::task::spawn_blocking({
let engine = engine.clone();
let query = query.clone();
let params = params.clone();
let principal = principal.clone();
move || {
dispatch_query_with_params(&engine, &query, ¶ms, principal.as_ref())
}
});
let abort_handle = handle.abort_handle();
match tokio::time::timeout(query_timeout, handle).await {
Ok(Ok(Ok(result))) => query_result_to_message(result),
Ok(Ok(Err(e))) => Message::Error {
message: sanitize_error(&e.to_string()),
},
Ok(Err(e)) => Message::Error {
message: format!("internal error: {e}"),
},
Err(_) => {
abort_handle.abort();
warn!(peer = %peer, query = %query, "query timeout exceeded");
Message::Error {
message: "query timeout exceeded".into(),
}
}
}
}
}
Message::Disconnect => {
debug!(peer = %peer, "received DISCONNECT");
break;
}
_ => Message::Error {
message: "unexpected message type".into(),
},
};
if !write_msg(&mut writer, &response).await {
break;
}
}
info!(peer = %peer, "client disconnected");
}
fn query_result_to_message(result: QueryResult) -> Message {
match result {
QueryResult::Rows { columns, rows } => {
let str_rows: Vec<Vec<String>> = rows
.iter()
.map(|row| row.iter().map(value_to_display).collect())
.collect();
Message::ResultRows {
columns,
rows: str_rows,
}
}
QueryResult::Scalar(val) => Message::ResultScalar {
value: value_to_display(&val),
},
QueryResult::Modified(n) => Message::ResultOk { affected: n },
QueryResult::Created(name) => Message::ResultMessage {
message: format!("type {name} created"),
},
QueryResult::Executed { message } => Message::ResultMessage { message },
}
}
fn value_to_display(v: &Value) -> String {
match v {
Value::Int(n) => n.to_string(),
Value::Float(n) => format!("{n}"),
Value::Bool(b) => b.to_string(),
Value::Str(s) => s.clone(),
Value::DateTime(t) => format!("{t}"),
Value::Uuid(u) => format!("{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
u[0], u[1], u[2], u[3], u[4], u[5], u[6], u[7],
u[8], u[9], u[10], u[11], u[12], u[13], u[14], u[15]),
Value::Bytes(b) => format!("<{} bytes>", b.len()),
Value::Empty => "null".into(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn null_serializes_as_null_bareword_on_wire() {
assert_eq!(value_to_display(&Value::Empty), "null");
}
#[test]
fn unique_violation_error_surfaces_to_remote_clients() {
assert_eq!(
sanitize_error("unique constraint violation on User.email"),
"unique constraint violation on User.email"
);
}
#[test]
fn internal_errors_stay_generic() {
assert_eq!(
sanitize_error("some internal io panic detail"),
"query execution error"
);
}
fn parsed(q: &str) -> powdb_query::ast::Statement {
parser::parse(q).unwrap()
}
fn principal(role: &str) -> Option<Principal> {
Some(Principal {
name: "u".into(),
role: role.into(),
})
}
#[test]
fn readonly_can_read_but_not_write() {
let p = principal("readonly");
assert!(check_statement_permitted(p.as_ref(), &parsed("User")).is_ok());
assert!(check_statement_permitted(p.as_ref(), &parsed("count(User)")).is_ok());
assert!(check_statement_permitted(p.as_ref(), &parsed("explain User")).is_ok());
for q in [
r#"insert User { name := "x" }"#,
"User filter .id = 1 update { age := 2 }",
"User filter .id = 1 delete",
"drop User",
"alter User add column c: str",
"type T { required id: int }",
"begin",
"commit",
"rollback",
] {
let err = check_statement_permitted(p.as_ref(), &parsed(q))
.expect_err(&format!("must deny: {q}"));
assert!(
err.to_string().contains("permission denied"),
"unexpected error for {q}: {err}"
);
}
}
#[test]
fn readwrite_and_admin_have_full_query_access() {
for role in ["readwrite", "admin"] {
let p = principal(role);
assert!(check_statement_permitted(p.as_ref(), &parsed("User")).is_ok());
assert!(check_statement_permitted(
p.as_ref(),
&parsed(r#"insert User { name := "x" }"#)
)
.is_ok());
assert!(check_statement_permitted(p.as_ref(), &parsed("drop User")).is_ok());
}
}
#[test]
fn unknown_role_fails_closed_for_writes() {
let p = principal("mystery");
assert!(check_statement_permitted(p.as_ref(), &parsed("User")).is_ok());
assert!(
check_statement_permitted(p.as_ref(), &parsed(r#"insert User { name := "x" }"#))
.is_err()
);
}
#[test]
fn no_principal_means_full_access() {
assert!(check_statement_permitted(None, &parsed("drop User")).is_ok());
assert!(check_statement_permitted(None, &parsed(r#"insert User { name := "x" }"#)).is_ok());
}
fn store_with_alice() -> UserStore {
let mut s = UserStore::new();
s.create_user("alice", "pw", "readwrite").unwrap();
s
}
#[test]
fn empty_store_no_password_is_open() {
let s = UserStore::new();
assert_eq!(
authenticate_connect(&s, None, None, None),
AuthOutcome::Authenticated { principal: None }
);
assert_eq!(
authenticate_connect(&s, None, Some("x"), Some("y")),
AuthOutcome::Authenticated { principal: None }
);
}
#[test]
fn empty_store_correct_shared_password_succeeds() {
let s = UserStore::new();
assert_eq!(
authenticate_connect(&s, Some("pw"), None, Some("pw")),
AuthOutcome::Authenticated { principal: None }
);
}
#[test]
fn empty_store_wrong_shared_password_rejected() {
let s = UserStore::new();
assert_eq!(
authenticate_connect(&s, Some("pw"), None, Some("bad")),
AuthOutcome::Rejected
);
}
#[test]
fn empty_store_missing_password_rejected_when_expected() {
let s = UserStore::new();
assert_eq!(
authenticate_connect(&s, Some("pw"), None, None),
AuthOutcome::Rejected
);
}
#[test]
fn empty_store_ignores_username_for_shared_password() {
let s = UserStore::new();
assert_eq!(
authenticate_connect(&s, Some("pw"), Some("whoever"), Some("pw")),
AuthOutcome::Authenticated { principal: None }
);
}
#[test]
fn user_auth_success_binds_principal() {
let s = store_with_alice();
assert_eq!(
authenticate_connect(&s, None, Some("alice"), Some("pw")),
AuthOutcome::Authenticated {
principal: Some(Principal {
name: "alice".into(),
role: "readwrite".into(),
})
}
);
}
#[test]
fn user_auth_wrong_password_rejected() {
let s = store_with_alice();
assert_eq!(
authenticate_connect(&s, None, Some("alice"), Some("bad")),
AuthOutcome::Rejected
);
}
#[test]
fn user_auth_unknown_user_rejected() {
let s = store_with_alice();
assert_eq!(
authenticate_connect(&s, None, Some("mallory"), Some("pw")),
AuthOutcome::Rejected
);
}
#[test]
fn user_auth_missing_username_rejected() {
let s = store_with_alice();
assert_eq!(
authenticate_connect(&s, None, None, Some("pw")),
AuthOutcome::Rejected
);
}
#[test]
fn user_auth_missing_password_rejected() {
let s = store_with_alice();
assert_eq!(
authenticate_connect(&s, Some("pw"), Some("alice"), None),
AuthOutcome::Rejected
);
}
#[test]
fn user_auth_ignores_shared_password_when_users_present() {
let s = store_with_alice();
assert_eq!(
authenticate_connect(&s, Some("shared"), None, Some("shared")),
AuthOutcome::Rejected
);
}
}